본문 바로가기

포인터 기초

메모리 주소를 저장하는 포인터의 선언과 주소·간접 참조 연산을 익히고 nullptr를 포함한 안전 조건을 이해합니다.

지금까지 변수는 이름으로만 다루었습니다. 하지만 함수가 호출한 쪽의 변수를 직접 바꾸거나, 실행 중에 만든 객체를 다루거나, 배열의 원소를 차례로 가리키려면 변수가 있는 위치 자체를 값으로 다룰 수 있어야 합니다. 그 위치인 메모리 주소를 담는 변수가 포인터(pointer)입니다.

이 글은 포인터를 선언하는 방법, 주소를 얻는 &와 가리키는 객체에 접근하는 *, 아무것도 가리키지 않는 nullptr, 그리고 간접 참조하기 전에 확인할 조건을 다룹니다. 처음에는 특정 주소의 숫자를 외우기보다, 포인터 변수의 값과 포인터가 가리키는 객체의 값을 구별하는 데 집중합니다.

메모리와 주소

변수를 선언하면 그 변수는 메모리의 어느 위치에 공간을 얻고, 그곳에 값이 저장됩니다. 이 위치를 나타내는 것이 메모리 주소(memory address)이며, 주소가 바이트마다 하나씩 붙는 방식은 비트·바이트·주소에서 다룹니다. int num = 10;이면 num이라는 객체가 int 하나의 공간을 얻고 그 안에 10이 들어갑니다.

실제 주소의 값은 실행할 때마다 달라질 수 있고, 출력하면 보통 16진수로 나타나지만 그 형식도 구현이 정합니다. 그래서 이 글의 예제는 주소 자체를 출력하는 대신, 두 주소가 같은지를 비교해 확인합니다.

포인터 선언

포인터는 가리킬 대상의 타입 뒤에 *를 붙여 선언합니다. int*는 int 객체를 가리키는 포인터, double*는 double 객체를 가리키는 포인터입니다.

pointer_declare.cpp
int* pNum = nullptr;     // int를 가리킬 포인터
double* pValue = nullptr; // double을 가리킬 포인터

int* a, b;  // a는 int*, b는 int

선언의 *는 그 이름 하나에만 붙습니다. 마지막 줄에서 a는 포인터지만 b는 그냥 int이므로, 포인터를 여러 개 선언할 때는 int* a; int* b;처럼 한 줄에 하나씩 적는 편이 안전합니다. 또 선언에 쓴 *는 이 변수가 포인터라는 표시일 뿐이며, 뒤에서 볼 식 안의 * 연산자와는 모양만 같고 하는 일이 다릅니다.

주소 연산자와 간접 참조 연산자

식에서 &변수는 그 변수의 주소를 얻는 주소 연산자(address-of operator)이고, *포인터는 포인터가 가리키는 객체 자체를 나타내는 간접 참조 연산자(indirection operator)입니다. *로 포인터를 따라가는 이 일을 간접 참조라고 하며, 역참조(dereference)라고도 부릅니다. *pNum은 가리키는 객체를 나타내므로, 값을 읽을 수도 있고 대입의 왼쪽에 두어 값을 바꿀 수도 있습니다.

address_and_indirection.cpp
#include <iostream>

int main() {
    int num = 10;
    int* pNum = &num;

    std::cout << std::boolalpha << (pNum == &num) << '\n';
    std::cout << *pNum << '\n';

    *pNum = 20;
    std::cout << num << ' ' << *pNum << '\n';
}
true
10
20 20

pNum = &num으로 pNum에는 num의 주소가 들어가므로, pNum == &num은 참입니다. *pNum은 num 자체이므로 처음에는 10을 읽고, *pNum = 20은 num의 값을 20으로 바꿉니다. num을 직접 출력해도 20이 나오는 것은 두 표현이 같은 객체를 가리키기 때문입니다.

포인터를 바꾸는 것과 대상을 바꾸는 것

포인터에 다른 주소를 대입하는 것과, 포인터가 가리키는 객체에 값을 대입하는 것은 서로 다른 일입니다. 아래 코드에서 p는 처음에 num을 가리키고, 가운데의 두 줄은 하나는 p 자체에, 다른 하나는 *p에 대입합니다. 마지막 출력의 p == &other는 두 대입이 끝난 뒤 p가 어느 객체를 가리키는지 확인합니다.

repoint.cpp
#include <iostream>

int main() {
    int num = 10;
    int other = 40;
    int* p = &num;

    p = &other;
    *p += 2;

    std::cout << num << ' ' << other << ' '
              << std::boolalpha << (p == &other) << '\n';
}
10 42 true

아래 그림처럼 처음에 p는 num을 가리키고, other는 따로 있는 객체입니다. p = &other는 p가 저장한 주소만 바꾸므로 num은 10 그대로이고, 이어지는 *p += 2는 지금 가리키는 other를 42로 만듭니다. 이 과정에서 새 객체가 만들어지지는 않으며, 포인터는 이미 있는 객체의 주소를 빌려 쓸 뿐입니다.

C++ 포인터 선언과 간접 참조의 주소 흐름

처음 p→num 10/other 40에서 p→other로 재지정하고 *p+=2로 other 42가 된다. 같은 객체의 두 시점이며 새 객체 생성으로 표현하지 않는다.

C++에서 선언자 별표와 단항 간접 참조 연산자를 구분하고, 객체에서 주소를 얻어 포인터에 저장한 뒤 같은 객체를 읽고 쓰는 흐름

DECLARATOR · ADDRESS-OF · INDIRECTION · LVALUE

같은 *도 선언과 식에서 역할이 다르다

int* pNum의 *는 포인터 선언자이고, *pNum의 *는 단항 간접 참조 연산자입니다. 기호를 따로 외우기보다 &num → pNum → *pNum으로 이어지는 객체와 포인터 값의 관계를 추적합니다.

포인터 재지정은 대상 관계를 바꾸고 간접 대입은 대상 값을 바꾼다두시점의논리적snapshot이며실제객체를두번할당한뜻이아니다.처음p는num10을가리키고other40이다.p를other주소로재지정하고간접대입으로2를더한뒤p는other42를가리키고num10은그대로다.각시점3노드1관계간선총6노드2간선이며시간간선은그리지않는다.모바일도같은포인터대상관계를유지한다.처음p저장한 주소num10other40재지정·대입 뒤p저장한 주소num10other42화살표는 현재 가리키는 대상두 구획은 같은 객체의 시점별 상태
포인터 재지정은 대상 관계를 바꾸고 간접 대입은 대상 값을 바꾼다두시점의논리적snapshot이며실제객체를두번할당한뜻이아니다.처음p는num10을가리키고other40이다.p를other주소로재지정하고간접대입으로2를더한뒤p는other42를가리키고num10은그대로다.각시점3노드1관계간선총6노드2간선이며시간간선은그리지않는다.모바일도같은포인터대상관계를유지한다.처음p저장한 주소num10other40재지정·대입 뒤p저장한 주소num10other42화살표는 현재 가리키는 대상두 구획은 같은 객체의 시점별 상태
declaration

int* pNum = &num;

선언자 *는 pNum이 int 객체를 가리킬 수 있는 포인터 변수임을 나타냅니다. 여기서는 &num이 만든 포인터 값을 초기값으로 저장합니다.

expression

*pNum

단항 *는 유효한 pNum이 가리키는 int 객체를 나타내는 lvalue를 만듭니다. 그래서 값을 읽을 수도 있고 대입의 왼쪽에서 쓸 수도 있습니다.

object → pointer value → object

네 식을 한 객체의 상태 변화로 읽는다

  1. int num = 10;

    int 객체 num을 만들고 값 10을 저장합니다. 특정 숫자 주소를 가정할 필요는 없습니다.

  2. &num을 pNum에 저장

    주소 연산자는 num을 가리키는 포인터 값을 만들고, 포인터 객체 pNum이 그 값을 보관합니다.

  3. int observed = *pNum;

    *pNum이 나타내는 num에서 값을 읽으므로 observed는 10으로 초기화됩니다.

  4. *pNum = 20;

    같은 lvalue에 값을 대입하므로 포인터 자체가 아니라 num의 저장된 값이 20으로 바뀝니다.

포인터 예제의 네 표현을 읽는 관점
표현무엇을 나타내는가결과 타입과 용도
num원래 int 객체값을 읽거나 쓸 수 있습니다.
&numnum을 가리키는 포인터 값int* 값으로 pNum을 초기화합니다.
pNum포인터 값을 저장하는 객체가리키는 대상을 바꾸거나 간접 참조에 사용합니다.
*pNumpNum이 가리키는 numint lvalue로 읽기와 쓰기가 가능합니다.

num

원래 int 객체이며 값을 읽거나 쓸 수 있습니다.

&num

num을 가리키는 int* 값입니다.

pNum

포인터 값을 저장하는 별도 객체입니다.

*pNum

가리키는 num을 나타내는 int lvalue입니다.

포인터 타입은 간접 참조 결과와 허용되는 연산의 의미를 정하지만, 임의의 저장 공간을 저절로 int 객체로 만들지는 않습니다. 실제 객체의 존재, 수명, 접근 규칙이 먼저 성립해야 합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명두 시점의 논리적 snapshot이며 실제 객체를 두 번 할당했다는 뜻이 아니다. 처음 p는 num 10을 가리키고 other는 40이다. p를 other 주소로 재지정하고 간접 대입으로 2를 더한 뒤 p는 other 42를 가리키고 num 10은 그대로다. 각 시점은 3개 노드와 1개 관계 간선으로 구성되어 총 6개 노드와 2개 간선이며 시간 간선은 그리지 않는다. 모바일도 같은 포인터 대상 관계를 유지한다.
본문·주석이 만든 포인터 값을 초깃값으로 저장합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
처음 연결

p는 값이 10인 num을 가리키고, 값이 40인 other는 별도 객체다.

  • p: num
  • num / other: 10 / 40
  • 포인터의 현재 대상과 객체 값은 별개다.
재지정

p에 other의 주소를 저장한다.

  • p: other
  • num / other: 10 / 40
  • 주소 재지정만으로 두 객체의 값을 변경하지 않는다.
간접 변경

*p에 2를 더한다.

  • other: 42
  • num: 10
  • 현재 대상 other만 간접 접근으로 변경된다.

C++ 포인터 선언과 간접 참조의 주소 흐름

처음 p→num 10/other 40에서 p→other로 재지정하고 *p+=2로 other 42가 된다. 같은 객체의 두 시점이며 새 객체 생성으로 표현하지 않는다.

C++에서 선언자 별표와 단항 간접 참조 연산자를 구분하고, 객체에서 주소를 얻어 포인터에 저장한 뒤 같은 객체를 읽고 쓰는 흐름

DECLARATOR · ADDRESS-OF · INDIRECTION · LVALUE

같은 *도 선언과 식에서 역할이 다르다

int* pNum의 *는 포인터 선언자이고, *pNum의 *는 단항 간접 참조 연산자입니다. 기호를 따로 외우기보다 &num → pNum → *pNum으로 이어지는 객체와 포인터 값의 관계를 추적합니다.

포인터 재지정은 대상 관계를 바꾸고 간접 대입은 대상 값을 바꾼다두시점의논리적snapshot이며실제객체를두번할당한뜻이아니다.처음p는num10을가리키고other40이다.p를other주소로재지정하고간접대입으로2를더한뒤p는other42를가리키고num10은그대로다.각시점3노드1관계간선총6노드2간선이며시간간선은그리지않는다.모바일도같은포인터대상관계를유지한다.처음p저장한 주소num10other40재지정·대입 뒤p저장한 주소num10other42화살표는 현재 가리키는 대상두 구획은 같은 객체의 시점별 상태
포인터 재지정은 대상 관계를 바꾸고 간접 대입은 대상 값을 바꾼다두시점의논리적snapshot이며실제객체를두번할당한뜻이아니다.처음p는num10을가리키고other40이다.p를other주소로재지정하고간접대입으로2를더한뒤p는other42를가리키고num10은그대로다.각시점3노드1관계간선총6노드2간선이며시간간선은그리지않는다.모바일도같은포인터대상관계를유지한다.처음p저장한 주소num10other40재지정·대입 뒤p저장한 주소num10other42화살표는 현재 가리키는 대상두 구획은 같은 객체의 시점별 상태
declaration

int* pNum = &num;

선언자 *는 pNum이 int 객체를 가리킬 수 있는 포인터 변수임을 나타냅니다. 여기서는 &num이 만든 포인터 값을 초기값으로 저장합니다.

expression

*pNum

단항 *는 유효한 pNum이 가리키는 int 객체를 나타내는 lvalue를 만듭니다. 그래서 값을 읽을 수도 있고 대입의 왼쪽에서 쓸 수도 있습니다.

object → pointer value → object

네 식을 한 객체의 상태 변화로 읽는다

  1. int num = 10;

    int 객체 num을 만들고 값 10을 저장합니다. 특정 숫자 주소를 가정할 필요는 없습니다.

  2. &num을 pNum에 저장

    주소 연산자는 num을 가리키는 포인터 값을 만들고, 포인터 객체 pNum이 그 값을 보관합니다.

  3. int observed = *pNum;

    *pNum이 나타내는 num에서 값을 읽으므로 observed는 10으로 초기화됩니다.

  4. *pNum = 20;

    같은 lvalue에 값을 대입하므로 포인터 자체가 아니라 num의 저장된 값이 20으로 바뀝니다.

포인터 예제의 네 표현을 읽는 관점
표현무엇을 나타내는가결과 타입과 용도
num원래 int 객체값을 읽거나 쓸 수 있습니다.
&numnum을 가리키는 포인터 값int* 값으로 pNum을 초기화합니다.
pNum포인터 값을 저장하는 객체가리키는 대상을 바꾸거나 간접 참조에 사용합니다.
*pNumpNum이 가리키는 numint lvalue로 읽기와 쓰기가 가능합니다.

num

원래 int 객체이며 값을 읽거나 쓸 수 있습니다.

&num

num을 가리키는 int* 값입니다.

pNum

포인터 값을 저장하는 별도 객체입니다.

*pNum

가리키는 num을 나타내는 int lvalue입니다.

포인터 타입은 간접 참조 결과와 허용되는 연산의 의미를 정하지만, 임의의 저장 공간을 저절로 int 객체로 만들지는 않습니다. 실제 객체의 존재, 수명, 접근 규칙이 먼저 성립해야 합니다.

이 예의 조건

  • 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
  • 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.

관계도의 보충 설명

원 도표의 제목·노드·관계·조건·주석
범위원문
관계·조건 설명두 시점의 논리적 snapshot이며 실제 객체를 두 번 할당했다는 뜻이 아니다. 처음 p는 num 10을 가리키고 other는 40이다. p를 other 주소로 재지정하고 간접 대입으로 2를 더한 뒤 p는 other 42를 가리키고 num 10은 그대로다. 각 시점은 3개 노드와 1개 관계 간선으로 구성되어 총 6개 노드와 2개 간선이며 시간 간선은 그리지 않는다. 모바일도 같은 포인터 대상 관계를 유지한다.
본문·주석이 만든 포인터 값을 초깃값으로 저장합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
처음 연결

p는 값이 10인 num을 가리키고, 값이 40인 other는 별도 객체다.

  • p: num
  • num / other: 10 / 40
  • 포인터의 현재 대상과 객체 값은 별개다.
재지정

p에 other의 주소를 저장한다.

  • p: other
  • num / other: 10 / 40
  • 주소 재지정만으로 두 객체의 값을 변경하지 않는다.
간접 변경

*p에 2를 더한다.

  • other: 42
  • num: 10
  • 현재 대상 other만 간접 접근으로 변경된다.

nullptr

지역 포인터를 초기화하지 않으면 값이 정해지지 않으며, 그런 포인터를 읽거나 간접 참조하면 정의되지 않은 동작입니다. 그래서 포인터는 선언할 때 가리킬 객체의 주소나, 아무것도 가리키지 않는다는 뜻의 nullptr로 초기화합니다.

null_check.cpp
#include <iostream>

int main() {
    int* ptr = nullptr;
    if (ptr == nullptr) {
        std::cout << "no target\n";
    }
}
no target

nullptr은 C++11에서 들어온 널 포인터 리터럴입니다. C에서 쓰던 NULL은 정수 0으로 정의될 수 있어서, f(int)와 f(int*)가 오버로드되어 있으면 f(NULL)이 f(int)를 고르거나 모호해질 수 있습니다. nullptr은 포인터로만 변환되므로 f(nullptr)은 언제나 f(int*)를 고릅니다.

널 포인터를 간접 참조하는 것도 정의되지 않은 동작입니다. Windows에서는 보통 접근 위반(access violation)으로, Linux에서는 세그멘테이션 오류(segmentation fault)로 프로그램이 끝나지만, 그렇게 끝난다는 보장은 없습니다. 그래서 포인터가 비어 있을 수 있다면 간접 참조하기 전에 nullptr인지 먼저 확인합니다.

더 깊이: 간접 참조한 뒤의 널 검사가 사라지는 이유

널 포인터를 간접 참조하면 정의되지 않은 동작이라는 규칙은 컴파일러의 최적화에도 쓰입니다. GCC는 대부분의 대상에서 기본으로 켜져 있는 -fdelete-null-pointer-checks에 따라, 프로그램이 널 포인터를 안전하게 간접 참조할 수 없고 주소 0에는 코드나 데이터가 없다고 가정합니다. 그래서 int v = *p; 다음에 if (p == nullptr)로 검사하는 코드가 있으면, 이미 간접 참조한 포인터는 널일 수 없다고 보고 그 검사를 지울 수 있습니다. 검사가 간접 참조보다 뒤에 있으면 최적화한 빌드에서는 그 검사가 아무 일도 하지 않을 수 있다는 뜻입니다.

-O2 이상에서 켜지는 -fisolate-erroneous-paths-dereference는 널 포인터를 간접 참조하게 되는 경로를 찾아, 그 문장을 프로그램을 멈추는 트랩(trap) 명령으로 바꿉니다. 이 가정이 맞지 않는 환경에서는 -fno-delete-null-pointer-checks로 앞의 최적화를 끌 수 있으며, GCC 문서에 따르면 AVR과 MSP430 대상에서는 이 옵션이 아예 꺼져 있습니다. 어느 경우든 널 검사는 간접 참조보다 앞에 두어야 의미가 있습니다.

널이 아니라고 안전한 것은 아닙니다

ptr != nullptr은 널이 아니라는 것만 알려 줄 뿐, 간접 참조해도 된다는 뜻은 아닙니다. 아래 그림처럼 간접 참조하기 전에는 실제 객체를 가리키는지, 그 객체가 아직 살아 있는지, 배열이라면 범위 안인지, 타입이 맞고 쓰기가 허용되는지를 함께 확인해야 합니다.

C++ 포인터가 널이 아니라는 검사만으로는 간접 참조가 안전하지 않으며, 실제 객체와 수명, 범위, 타입, 정렬, 쓰기 가능성을 별도로 확인해야 한다는 점검표

NONNULL ≠ DEREFERENCEABLE

널 검사만으로 안전을 증명할 수 없다

비교 가능한 포인터 값이라는 전제에서 ptr != nullptr는 널 포인터 값이 아니라는 사실만 확인합니다. 객체 포인터를 간접 참조하려면 지금 접근 가능한 실제 객체의 유효한 위치를 가리키는지까지 따져야 합니다.

nonnull, still invalid

널이 아니어도 간접 참조할 수 없는 예

배열의 one-past 포인터는 같은 배열 범위에서 허용되는 비교·산술에는 쓸 수 있지만 간접 참조할 수 없습니다. 수명이 끝난 객체나 해제된 저장 공간을 가리키던 포인터 값은 사용을 피해야 합니다.

dereferenceable

유효한 대상과 허용된 접근이 함께 필요하다

대상 객체가 살아 있고, 현재 포인터가 그 객체의 유효한 범위를 가리키며, 타입·정렬·접근 권한이 연산과 맞을 때만 읽거나 쓸 수 있습니다.

pre-dereference checklist

간접 참조 직전에는 네 질문을 순서대로 확인한다

  1. 실제 객체를 가리키는가?

    nullptr가 아니면서, 배열 끝 바로 다음을 뜻하는 one-past 위치가 아니라 간접 참조 가능한 객체 또는 하위 객체를 가리켜야 합니다.

  2. 대상의 수명과 저장 공간이 유효한가?

    지역 범위가 끝났거나 delete된 대상처럼 이미 수명이 끝난 객체의 주소를 보관하고 있지 않은지 확인합니다.

  3. 현재 접근이 범위 안에 있는가?

    배열 원소나 객체의 하위 영역을 다룰 때 포인터가 해당 유효 범위 안을 가리키는지 확인합니다.

  4. 타입·정렬·쓰기 조건이 맞는가?

    가리키는 객체를 해당 타입으로 접근할 수 있고 정렬 조건이 맞는지, 쓰기라면 const 객체나 읽기 전용 저장 공간이 아닌지 확인합니다.

access validity

“지금 이 포인터로 읽고 써도 되는가?”

널, 수명, 범위, 타입, 정렬, 변경 가능성은 한 번의 접근이 C++ 객체 모델에서 유효한지를 판단하는 조건입니다.

resource policy

“누가 자원을 소유하고 언제 해제하는가?”

소유권은 별도 설계 문제입니다. RAII 컨테이너와 스마트 포인터에 소유를 맡기고 raw T*를 비소유 관찰자로 쓰는 방식은 널리 쓰이는 Core Guidelines 관례이지, 모든 raw 포인터에 적용되는 ISO C++ 언어 규칙은 아닙니다.

널 포인터 간접 참조와 댕글링·범위 밖·잘못된 타입 접근은 정의되지 않은 동작이 될 수 있습니다. 충돌은 흔한 증상일 뿐 보장된 진단이 아니므로, 실행 결과로 유효성을 판정하지 않습니다.

가장 흔한 실수는 수명이 끝난 객체의 주소를 계속 들고 있는 것입니다. 이런 포인터를 허상 포인터(dangling pointer)라고 합니다.

dangling.cpp
int* p = nullptr;
{
    int local = 5;
    p = &local;
} // local의 수명이 여기서 끝남

// *p는 정의되지 않은 동작

블록이 끝나면 local은 사라지지만, p에는 그 주소가 그대로 남아 널이 아닙니다. 이 상태에서 *p를 읽으면 우연히 5가 나올 수도 있고 다른 값이 나올 수도 있어서, 실행해 보는 것으로는 문제를 확인할 수 없습니다. 함수가 지역 변수의 주소를 반환하는 것도 같은 실수이며, GCC는 -Wreturn-local-addr로 이를 경고합니다.

포인터의 크기

포인터 변수가 차지하는 크기는 구현이 정합니다. 64비트 Windows의 MSYS2 GCC에서는 다음 세 포인터가 모두 8바이트입니다.

pointer_size.cpp
#include <iostream>

int main() {
    std::cout << sizeof(int*) << ' ' << sizeof(double*) << ' '
              << sizeof(char*) << '\n';
}
8 8 8

가리키는 대상의 크기와 상관없이 포인터의 크기가 같은 것은, 포인터가 담는 것이 대상 전체가 아니라 그 위치이기 때문입니다. 다만 표준은 모든 포인터의 크기가 같다고 보장하지 않으므로, 크기가 필요하면 sizeof로 확인하고 파일이나 네트워크에 포인터 값을 그대로 저장하지 않습니다. 포인터의 대상 타입은 간접 참조했을 때 어떤 타입의 객체로 다룰지와, 포인터와 배열에서 볼 포인터 산술의 간격을 정합니다.

포인터를 쓰는 곳

포인터는 실행 중에 만든 객체를 다루는 동적 메모리, 배열의 원소를 차례로 가리키는 일, C API와의 연결, 연결 리스트 같은 자료 구조의 내부, 운영체제나 하드웨어와 맞닿은 코드에 쓰입니다. 대상이 없을 수 있다는 것을 nullptr로 나타낼 수 있다는 점도 포인터의 쓰임새입니다.

하지만 현대 C++에서는 메모리를 소유하는 포인터와 직접적인 new, delete를 기본으로 쓰지 않습니다. 동적 메모리는 std::vector나 std::unique_ptr가 맡고, 반드시 대상이 있는 매개변수는 참조자로, 연속된 원소는 std::span으로 넘깁니다. 포인터는 이런 도구로 표현하기 어려운 곳에, 무엇을 가리키는지 분명할 때만 씁니다.

정리

  • 포인터는 다른 객체의 메모리 주소를 담는 변수이며, int*처럼 가리킬 대상의 타입과 *로 선언합니다.
  • int* a, b;에서 b는 포인터가 아니므로, 포인터는 한 줄에 하나씩 선언합니다.
  • &x는 x의 주소를 얻고, *p는 p가 가리키는 객체 자체를 나타내 읽거나 바꿀 수 있습니다.
  • 포인터에 다른 주소를 대입하는 것과 *p에 값을 대입하는 것은 서로 다른 일입니다.
  • 포인터는 nullptr이나 올바른 주소로 초기화하며, nullptr을 간접 참조하면 정의되지 않은 동작입니다.
  • 널이 아니어도 수명이 끝난 객체를 가리키는 허상 포인터일 수 있으므로, 대상의 수명과 범위까지 확인합니다.

연습 문제

1. 재지정 대신 값 복사

repoint.cpp에서 p = &other;만 *p = other;로 바꾸면 출력은 무엇인가요?

해설 보기

p는 계속 num을 가리킵니다. *p = other는 other의 값 40을 num에 복사하고, 이어서 *p += 2가 num을 42로 만듭니다. other는 40 그대로이고 p는 other를 가리키지 않으므로 출력은 42 40 false입니다.

2. 한 줄에 선언한 두 변수

int x = 1; int* a, b; 다음에 a = &x;와 b = &x;를 쓰면 각각 어떻게 되나요?

해설 보기

a는 int*이므로 a = &x;는 올바릅니다. b는 int이므로 b = &x;는 int*를 int에 대입하려는 것이어서 컴파일 오류입니다. *는 바로 뒤의 이름 하나에만 붙기 때문이며, 두 변수를 모두 포인터로 만들려면 int* a; int* b;처럼 따로 선언합니다.

3. 블록이 끝난 뒤의 포인터

dangling.cpp의 블록이 끝난 뒤, p != nullptr은 참인가요? 이 검사를 통과했으니 *p를 읽어도 되나요?

해설 보기

p에는 local의 주소가 그대로 남아 있으므로 p != nullptr은 참입니다. 하지만 local의 수명이 끝났으므로 *p를 읽는 것은 정의되지 않은 동작이며, 널 검사는 이 문제를 잡아 주지 못합니다. 포인터가 가리키는 객체의 수명이 포인터를 쓰는 동안 이어지는지는 코드의 구조로 보장해야 합니다.

다음 글 동적 메모리 할당에서는 실행 중에 필요한 만큼 객체를 만드는 new와 해제하는 delete, 그리고 이 일을 대신 맡는 std::vector와 std::unique_ptr를 다룹니다.