상세 컨텐츠

본문 제목

[C++] 포인터

C++

by 이나시오- 2024. 3. 19. 02:30

본문

  C++ 문법의 꽃, 포인터에 대해 정리해보자.

 

    포인터란?               

  포인터는 "메모리 주소"를 다루는 문법이다. 내가 어떤 변수를 선언하면, 어딘가의 메모리 주소에 저장된다. 변수의 종류에 따라 다르겠지만, 스택 영역, ROM, 힙 영역에 저장될 것이다. 이에 대한 내용은 아래 포스트를 확인하면 더 자세히 알 수 있다.

 

 

변수의 종류와 메모리 영역

이번에 C++을 다시 공부하면서 내가 작성한 코드가 실제로 어떻게 작동하는지 이해하는 것이 중요하다는 것을 알게 되었다. 그래서 컴퓨터 구조와 운영체제에 대해서 처음 공부를 시작하게 되었

iveiam.tistory.com

 

  주소를 왜 다루는지 여러 가지 이유가 있겠지만 내가 생각하기에 중요한 이유는 2가지 정도가 있다.

 

1. 복사된 값이 아닌, 이미 저장되어 있는 값을 수정하고 싶은 경우.

2. 필요한 데이터의 크기가 매우 커서 복사하는데 비용이 부담스러운 경우

 

  두 case 모두, 함수의 인자가 그 함수의 지역변수라는 사실을 알면 이해하기 쉬울 것이다.

 

  우선 1번의 경우에 대해 이해해보자.

#include <iostream>

int func(int iVal)
{
    iVal = 10;
}


int main()
{
    int a = 0;
    func(a);
    
    return 0;
}

 

  위 코드에서, func()이라는 함수는 int형 타입 변수를 인자로 받아 10으로 수정하는 기능을 수행한다. main() 함수에서 func(a)로 이 함수를 호출하는데, 여기서 a가 직접 iVal에 들어가는 것이 아니고, 실제로는 iVal이라는 func()의 지역 변수에 a의 값이 복사되는 것이다. 따라서, iVal을 10으로 수정해도 func() 스택에 있는 iVal 지역 변수의 값이 10으로 수정된 후 스택이 사라지는 아무 의미 없는 행동을 하고 있는 것이다.

 

  2번째 case도 알아보자. 

#include <iostream>

struct myStruct
{
    int a1;
    int a2;
    // ...
    int a50;
};

int func(myStruct _st)
{
    // .. do something with _st
}


int main()
{
    myStruct my = {};
    func(my);
    
    return 0;
}

 

  위 코드에서, myStruct 타입의 사이즈는 200byte이다. 이 타입의 변수를 받아서 무언가 수행하는 func() 함수가 있다. 이 때, main() 함수에서 myStruct 타입의 변수 my를 선언하고 func()의 인자로 넣었을 때, 위에서 알아본 것 처럼 func()의 지역 변수 _st에 모든 값이 복사된다. 만약 이 200byte라는 사이즈가 부담되는 경우에, func() 호출 스택이 최소 200byte 이상이므로 func() 호출이 부담스러울 것이다. 이러한 경우 데이터가 실제로 저장된 주소를 받는 것이 더 이득일 수 있다.

 

    포인터 사용법        

  포인터는 자료형 뒤에 *을 붙여서 사용할 수 있다. 예를 들어, int*, float* 등으로 사용하면 된다. 이러한 포인터 변수는 그 자료형의 주소를 저장한다. 이 때 주소값은 윈도우 기준 플랫폼에 따라 달라진다. x86 CPU는 32bit, x64 CPU는 64bit이다. 이 크기는 CPU가 한 번에 명령어를 처리하는 사이즈로, word라고도 부른다. 이에 따라 32bit 운영체제는 주소값이 32bit이고, 64bit 운영체제는 주소값이 64bit 크기이다. 따라서 포인터의 사이즈는 64bit 플랫폼 기준으로 8byte라고 이해하면 된다.

 

  여담으로, 이 word 사이즈에 의해 최대로 사용할 수 있는 메모리의 사이즈가 달라진다. 32bit 운영체제라면, 주소 값의 크기가 32bit이므로 2^32 가지의 주소를 표현할 수 있다. 이 때, 주소의 단위는 byte이므로 최대 2^32byte 만큼만 메모리를 다룰 수 있는 것이다. 2^32byte = 2^22KiB = 2^12MiB = 2^2GiB이므로 32bit 운영체제에서 4GiB이상의 램을 사용해도 성능이 더 높아질 것으로 기대하긴 어렵다는 것이다. 물론 이를 극복하기 위해 다른 방식으로 주소를 다룰 수 있다는데, 이에 대해서는 알아보지 않았다.

 

  다시 돌아와서 포인터의 사용법을 알아보자.

 

[포인터와 주소]

 

  포인터는 주소를 저장한다고 했다. 아래 코드에서 pI라는 int 포인터는 처음에 nullptr라는 값을 받으며 초기화되었다. nullptr는 아무 주소도 가리키지 않음을 의미하고, 실제 값은 0에 해당한다. 포인터가 아무 주소도 가리키고 있지 않다는 것을 명시하기 위해 nullptr을 사용한다.

 

  이후, pI=&a;라고 값을 할당받는데, &a는 a의 주소를 나타낸다. 어떤 변수 앞에 '&'을 붙인다면 그 변수의 주소를 반환하므로, pI가 현재 a의 주소를 저장했다고 생각하면 된다. 이러한 과정을 "참조"라고 한다.

 

  참조는 변수의 주소에 접근하는 것이고, "역참조"는 변수의 주소를 통해 변수의 값에 접근하는 것이다. 역참조 과정을 통해 주소에 저장되어 있는 실제 값을 알 수 있고, 수정도 할 수 있다. 아래 코드처럼 포인터 변수 앞에 '*'를 붙여서 역참조 할 수 있다. *pI=10;은 pI가 가리키고 있는 주소의 값, 즉 a를 10으로 수정하는 것이다.

#include <iostream>

int main()
{
    // 포인터와 주소
    int a = 0;
    int* pI = nullptr;
    pI = &a;
    
    // 역참조
    *pI = 10;

 

[포인터와 자료형, void 포인터] - 코드는 위와 이어진다고 생각해주길 바란다

 

  포인터 변수는 왜 자료형 뒤에 '*'를 붙이는 식으로 생기게 된걸까? 자료형에 대해 잘 이해하고 있다면 쉽게 이해할 수 있다. 포인터는 주소를 저장하지만, 더 정확히는 데이터의 시작 주소를 저장한다. 따라서 자료형을 모른다면 (1) 시작 주소로부터 어디까지 얼마나 접근해야 하는지 알 수 없다. 하지만 자료형을 안다면, 자료형의 사이즈만큼 접근하면 되므로 이 문제가 해결된다. 또한, 자료형을 모른다면 (2) 데이터를 어떤 식으로 해석할 지 모른다는 문제 또한 해결할 수 있다. 예를 들어, 같은 4byte 자료형이어도 int 타입, float 타입은 전혀 다른 방식으로 데이터를 저장한다. 이에 대한 내용은 아래 포스트에서 확인할 수 있다. 따라서 동일하게 4byte에만 접근하더라도, 데이터를 어떤 자료형으로 해석할 지에 대한 정보가 필요한 것이다. 이와 같은 이유로 포인터 변수에 자료형 정보가 필요함을 알 수 있다.

 

  아래 코드에서 float* pF = &a; 라는 부분은 컴파일 에러가 난다. float포인터로 int 타입 변수의 주소를 받으려고 했기 때문이다. 과거에는 컴파일 에러가 나지 않았으나, visual studio가 발전하면서 컴파일 에러로 바뀌었다고 한다. 반면, 포인터도 형변환이 가능한데, a의 주소를 float 포인터 타입으로 casting 해주면 float 포인터 변수에도 값을 저장할 수 있다. 다만 이 경우 pF를 역참조 했을 때, 값이 매우 달라지므로 주의해야 한다.

 

  자료형을 알 수 없는 void 포인터도 존재한다. void 포인터는 위에서 말한 문제를 모두 껴안고 가지만, 어떤 타입이든지 주소를 받을 수 있다는 장점이 있다. 여기서 위에서 언급한 2가지 문제 때문에 직접 역참조를 할 수가 없다. 하지만 void 포인터 역시 형변환을 해주면 역참조가 가능해진다.

    // 포인터와 자료형, void 포인터
    // float* pF = &a;  // 컴파일 에러
    float* pF = (float*) &a;
    
    void* pV = &a;
    // *pV;		// 컴파일 에러. void포인터로 역참조 불가능하다.
 

자료형 (Data Type)

자료형은 어떤 데이터를 메모리에 저장하기 위해 정해진 형식이다. 자료형을 통해 내가 보고 있는 메모리가 어떤 형태의 데이터인지를 알 수 있다. 자료형의 핵심은 메모리의 크기 (size)와 그 메

iveiam.tistory.com

 

[주소 연산]

 

  주소의 단위는 1byte이다. 그렇다면 포인터 변수에 1을 더하는 연산을 하면 어떻게 될까? 직관적으로는 1byte가 더해질 것 같지만 실제로 그렇게 동작하지 않는다. 위에서 강조한 자료형의 중요성이 여기서도 드러난다. 배열과 같은 연속적인 메모리에 데이터가 여러 개 저장된 경우, 주소 연산 + 1은 그 데이터의 자료형의 size만큼 주소 값을 증가시킨다. 아래 코드를 보면서 구체적으로 알아보자.

 

  크기 3의 배열 arrInt를 선언하고, 그 시작 주소를 int 포인터 pArr에 저장했다. pArr에 + 0을 하면 당연히 그대로 pArr이고, 여기에 역참조를 해서 10을 대입하면 배열의 첫 번째 4byte에 10이라는 정수가 저장된다. 즉, arrInt[0]이 10이 되는 것이다. 두 번째로, pArr에 1을 더해서 그 자리를 역참조하면, 자료형의 size, 즉 4byte 만큼 주소가 증가한다. 따라서 처음 대입한 *(pArr) 자리에 겹치지 않고 다음 4byte에 20이라는 정수를 저장하는 것이다. 이런 방식으로 주소 연산 +1을 할 때 자료형의 사이즈 만큼 건너뛰면서 쉽게 다음 자리를 찾을 수 있다.

 

  우리가 배열 인덱스 접근에서 [ ]를 알고 있는데, 이것의 유래가 사실 포인터 변수의 역참조이다. pArr[i]과 동일한 것이 *(pArr+i)이다. 즉, i칸 만큼 건너뛴 후 역참조 하는 것이므로, *(pArr+i)는 pArr[i]과 동일하고, 이는 곧 arrInt[i]와 동일하게 되는 것이다.

    // 주소 연산
    int arrInt[3] = {};
    int* pArr = arrInt;
    
    *(pArr+0) = 10;
    *(pArr+1) = 20;
    *(pArr+2) = 30;
    
    pArr[0] = 10;
    pArr[1] = 20;
    pArr[2] = 30;
    

    return 0;
}

 

 

 

'C++' 카테고리의 다른 글

[C++] 함수 객체 (Functor)  (0) 2024.08.08
[C++] 함수 포인터  (0) 2024.03.19
[C++] Struct (구조체)  (0) 2024.03.12
[C++] 변수의 종류와 메모리 영역  (0) 2024.03.01
[C++] 자료형 (Data Type)  (0) 2024.02.29

관련글 더보기