다른 많은 언어와 마찬가지로 C++에도 _포인터_가 있어요. 이미 _참조_와 _포인터_가 비슷하다는 것은 알고 있겠지만, 이 둘을 컴퓨터 내부 동작에 한 단계 더 가까이 있는 것으로 생각해 봐요. _포인터_는 객체의 주소를 담는 변수예요. 포인터는 객체와 직접 상호작용하는 데 사용되며, C++에서 동적 메모리 할당과 효율적인 데이터 조작을 가능하게 해 줘요.
_포인터_가 처음이라면 조금 신비롭게 느껴질 수도 있지만, 익숙해지고 나면 아주 간단해요.
포인터는 C++에서 아주 중요한 부분이니, 시간을 들여 제대로 이해해 봐요. 이 개념에서 다루는 가장 기본적인 형태는 덤 포인터 또는 _로우 포인터_라고도 불러요. 최신 C++에는 _스마트 포인터_도 있는데, 기본형은 전혀 똑똑하지 않아서 모든 작업을 직접 처리해야 해요.
자세히 살펴보기 전에, _포인터_의 용도를 이해해 두면 좋아요. _포인터_는 우리 프로그램의 다른 부분과 객체의 주소를 공유하는 방법이고, 이는 크게 두 가지 이유에서 유용해요:
C++에서 포인터를 선언할 때는 포인터가 가리킬 데이터 타입을 지정한 뒤, 별표(*)와 포인터의 이름을 써요.
포인터는 선언할 때 자동으로 초기화되지 않아요.
명시적으로 값을 할당하지 않으면 포인터는 보통 불확정한 값을 가지는데, 이를 흔히 "가비지 주소"라고 불러요.
일부 컴파일러는 포인터를 nullptr로 초기화하기도 하지만, 모든 컴파일러에서 이 동작이 보장되는 것은 아니니 여기에 의존해서는 안 돼요.
잠재적인 문제를 피하려면 로우 포인터를 명시적으로 초기화하고, 사용하기 전에 널이 아닌지 확인하는 것이 가장 좋아요.
int* ptr{nullptr}; // Declares a pointer and makes sure it is not invalid
변수의 주소를 포인터에 할당하려면 주소 연산자(&)를 사용해요.
포인터의 역참조는 간접 참조 연산자(*)를 사용해요.
std::string opponent{"Solomon Lane"};
// 'ethan' points to the address of the string opponent
std::string* ethan{&opponent};
// Instead of ethan's, the opponent's name address is given to the passPort
std::string passportName{*ethan};
주의할 점이 있어요: 역참조는 명시적으로 해야 하지만, _참조_는 그냥 별칭처럼 동작해요.
_포인터 연산_을 사용하면 포인터에 산술 연산을 할 수 있는데, 배열을 다룰 때 특히 유용해요. 포인터에 정수를 더하면 다른 원소를 가리키게 돼요.
// Stargate Coordinate Code
int gateCode[] = {462, 753, 218, 611, 977};
// 'ptr' points to the first element of 'gateCode'
int* ptr{&gateCode[0]};
// Accesses the third Stargate address through pointer arithmetic
int dialedAddress{*(ptr + 2)};
// Chevron encoded! Dialing Stargate address:
openStarGate(dialedAddress);
C++의 포인터 연산은 조심히 다루지 않으면 쉽게 __정의되지 않은 동작__으로 이어질 수 있어요. 정의되지 않은 동작은 예상치 못한 프로그램 결과, 충돌, 심지어 보안 취약점으로 나타날 수 있어요. 정의되지 않은 동작의 결과를 보여 준 악명 높은 예로, 1996년 아리안 5호 로켓 폭발 사건이 있어요. 여기서는 64비트 부동소수점 수를 16비트 부호 있는 정수로 변환하면서 발생한 소프트웨어 예외가 치명적인 실패로 이어졌어요.
C++에서 -> 연산자는 객체를 가리키는 포인터를 통해 그 객체의 멤버에 접근할 때 사용해요.
포인터가 가리키는 객체의 멤버에 접근하는 일을 간단하게 만들어 주는 축약형이에요.
예를 들어 ptr이 멤버 변수 x를 가진 객체를 가리키는 포인터라면, (*ptr).x 대신 ptr->x를 바로 사용할 수 있어요.
이 연산자는 객체를 가리키는 포인터를 다룰 때 코드 가독성을 높이고 장황함을 줄여 줘요.
다음은 멤버 변수 superpower를 가진 구조체 Superhero의 간단한 예제예요.
main 함수는 Superhero 객체(원더 우먼을 나타내요)를 가리키는 포인터 dianaPrince를 만들어요.
-> 연산자로 멤버 변수 superpower에 접근하는데, 원더 우먼의 상징인 "진실의 올가미"를 보여 줘요.
struct Superhero {
std::string superpower;
};
Superhero wonder_woman{};
Superhero* dianaPrince = &wonder_woman;
dianaPrince->superpower = "Lasso of Truth";
// Using the -> operator to access member variable superpower:
std::cout << "Wonder Woman, possesses the mighty " << dianaPrince->superpower;
// Memory cleanup:
delete dianaPrince;
포인터와 참조는 둘 다 객체에 간접적으로 접근할 수 있게 해 주지만, 할 수 있는 일과 안전성 측면에서 차이가 있어요. 포인터는 가리키는 대상을 바꿀 수 있는 유연함을 제공하고, 널을 할당할 수도 있어요. 하지만 이런 유연함은 널 포인터를 역참조하거나 댕글링 포인터를 만드는 것과 같은 위험을 가져와요. 반면 참조는 널이 될 수 없고 만들어질 때 유효한 객체에 묶이기 때문에 이런 위험을 피할 수 있어요. 참조가 더 안전하므로, 포인터가 제공하는 추가 기능이 꼭 필요하지 않다면 포인터보다 참조를 선호하는 게 좋아요.
C++에서 new는 실행 중에 힙에 객체를 위한 메모리를 동적으로 할당할 때 사용해요.
할당된 메모리 주소를 가리키는 포인터를 반환해서, 객체가 특정 스코프에 묶이지 않는 수명을 가질 수 있게 해 줘요.
하지만 메모리를 제대로 해제하려면 new를 항상 delete와 짝지어 쓰는 것이 중요해요.
동적 메모리 할당은 메모리 누수나 해제된 메모리 접근 같은 흔한 문제를 막기 위해 신중하게 사용해야 해요.
포인터는 _널 포인터_라고 하는 특별한 값으로 설정할 수도 있는데, 이는 유효한 주소를 가리키지 않는다는 것을 나타내요. 널 포인터는 포인터에 유효한 값을 할당하기 전에 초기화하는 데 흔히 사용해요.
// There is a dark mark bearer, but who is it?
std::string* darkMarkBearer{nullptr};
// It is Bellatrix!
darkMarkBearer = new std::string{"Bellatrix Lestrange"};
// She has been defeated
delete darkMarkBearer;
// Reset pointer to null, symbolizing there is no more darkMarkBearer at the moment
darkMarkBearer = nullptr;
포인터는 유효한 객체의 주소를 담고 있거나, nullptr로 설정되어 있어야 해요.
그러면 포인터가 널인지만 검사하면 되고, 널이 아닌 포인터는 모두 유효하다고 가정할 수 있어요.
객체가 파괴되면 그 객체를 가리키던 포인터는 댕글링 상태로 남아요(자동으로 nullptr로 설정되지 않아요).
이런 경우를 찾아내고 그 포인터들을 이후에 nullptr로 설정하는 것은 여러분의 책임이에요.
오래된 코드에서는 nullptr의 두 가지 대안을 만날 수 있어요.
첫째, 리터럴 0은 포인터에서 특별히 널 값으로 해석돼요. 다만 이는 정수 리터럴을 포인터에 할당할 수 있는 유일한 경우예요.
둘째, C에서 물려받았고 <cstddef> 헤더에 정의된 preprocessor macro NULL은 널 포인터의 또 다른 표현이지만, 최신 C++ 코드에서는 사용이 덜 흔해요.