포인터

포인터 (Pointer)

포인터는 어떤 객체나 함수의 메모리 주소를 담는 변수예요. C++에서 포인터는 객체 포인터, void 포인터, 함수 포인터, 멤버 포인터까지 여러 형태가 있어서, 이 페이지에서는 포인터 선언 문법부터 시작해 각 종류와 널 포인터, 무효 포인터, const 처리까지 차근차근 다룰게요.

출처: cppreference

본문

포인터 선언(pointer declaration)은 declarator가 다음 형태를 가진 어떤 simple-declaration이에요.

* attr (optional) cv (optional) declarator      (1)
nested-name-specifier * attr (optional) cv (optional) declarator   (2)
  1. 포인터 declarator: 선언 S* D;D를 선언 지정자 시퀀스 S가 정하는 타입을 가리키는 포인터로 선언해요.
  2. 멤버 포인터 declarator: 선언 S C::* D;DC의 비정적 멤버(타입은 S로 결정)를 가리키는 포인터로 선언해요.

포인터에 대한 참조는 없고, 비트 필드에 대한 포인터도 없어요. 보통 "포인터"라고만 말하면 (비정적) 멤버에 대한 포인터는 포함하지 않아요.

포인터 (Pointers)

포인터 타입의 모든 값은 다음 중 하나예요.

  • 객체나 함수를 가리키는 포인터(그 객체/함수를 가리킨다고 말함),
  • 객체의 끝을 지나는 포인터(past the end),
  • 그 타입의 널 포인터 값,
  • 무효 포인터 값(invalid pointer value).

객체를 가리키는 포인터는 그 객체가 차지하는 메모리의 첫 바이트 주소를 나타내요. 객체의 끝을 지나는 포인터는 객체의 저장 공간이 끝난 뒤 첫 바이트 주소를 나타내요.

같은 주소를 나타내는 두 포인터라도 값은 다를 수 있다는 점에 주의하세요.

struct C
{
    int x, y;
} c;

int* px = &c.x;   // value of px is "pointer to c.x"
int* pxe= px + 1; // value of pxe is "pointer past the end of c.x"
int* py = &c.y;   // value of py is "pointer to c.y"

assert(pxe == py); // == tests if two pointers represent the same address
                   // may or may not fire

*pxe = 1; // undefined behavior even if the assertion does not fire

무효 포인터 값을 통한 간접 참조와, 무효 포인터 값을 해제(deallocation) 함수에 전달하는 것은 undefined behavior예요. 무효 포인터 값의 다른 어떤 사용도 구현 정의(implementation-defined) 동작이에요. 어떤 구현은 무효 포인터 값을 복사하면 시스템이 런타임 오류를 일으킨다고 정의할 수도 있어요.

객체 포인터 (Pointers to objects)

객체 포인터는 객체 타입의 어떤 표현식에든 주소 연산자(address-of operator)를 적용한 반환값으로 초기화할 수 있어요(다른 포인터 타입 포함).

int n;
int* np = &n;          // pointer to int
int* const* npp = &np; // non-const pointer to const pointer to non-const int

int a[2];
int (*ap)[2] = &a;     // pointer to array of int

struct S { int n; };

S s = {1};
int* sp = &s.n;        // pointer to the int that is a member of s

포인터는 내장 간접 참조 연산자(단항 operator*)의 피연산자로 나타날 수 있는데, 이 연산자는 가리키는 객체를 식별하는 lvalue 표현식을 반환해요.

int n;
int* p = &n;     // pointer to n
int& r = *p;     // reference is bound to the lvalue expression that identifies n
r = 7;           // stores the int 7 in n
std::cout << *p; // lvalue-to-rvalue implicit conversion reads the value from n

클래스 객체에 대한 포인터는 멤버 접근 연산자 operator->operator->*의 왼쪽 피연산자로도 나타날 수 있어요.

배열→포인터 암시 변환 덕분에, 배열의 첫 요소를 가리키는 포인터는 배열 타입 표현식으로 초기화할 수 있어요.

int a[2];
int* p1 = a; // pointer to the first element a[0] (an int) of the array a

int b[6][3][8];
int (*p2)[3][8] = b; // pointer to the first element b[0] of the array b,
                     // which is an array of 3 arrays of 8 ints

포인터의 파생→기반 암시 변환 덕분에, 베이스 클래스에 대한 포인터는 파생 클래스의 주소로 초기화할 수 있어요.

struct Base {};
struct Derived : Base {};

Derived d;
Base* p = &d;

Derived가 다형적이면 그런 포인터로 가상 함수 호출을 할 수 있어요.

배열 요소를 가리키는 포인터에는 덧셈·뺄셈·증가·감소 연산자가 일부 정의돼 있어요. 그런 포인터는 LegacyRandomAccessIterator 요구사항을 만족해서 C++ 라이브러리 알고리즘이 raw 배열로도 동작하게 해줘요.

객체 포인터에는 비교 연산자가 어떤 상황에서 정의돼요. 같은 주소를 나타내는 두 포인터는 같게 비교되고, 두 널 포인터 값은 같게 비교되며, 같은 배열의 요소를 가리키는 포인터는 그 요소의 배열 인덱스처럼 비교되고, 멤버 접근이 같은 비정적 데이터 멤버 포인터는 그 멤버의 선언 순서대로 비교돼요.

많은 구현은 임의 기원(random origin)의 포인터에 엄격한 전체 순서(strict total ordering)를 제공해요. 예를 들어 연속된 가상 주소 공간의 주소로 구현된 경우죠. 그렇지 않은 구현(예: 포인터의 모든 비트가 메모리 주소가 아니어서 비교 시 무시해야 하거나, 추가 계산이 필요해 포인터와 정수가 1:1 관계가 아닌 경우)은 포인터에 대한 std::less 특수화를 제공하는데, 이것이 그 보장을 제공해요. 이를 통해 임의 기원의 모든 포인터를 std::set이나 std::map 같은 표준 연관 컨테이너의 키로 쓸 수 있어요.

void 포인터 (Pointers to void)

어떤 타입의 객체 포인터든 (cv-qualified) void 포인터로 암시 변환할 수 있어요. 포인터 값은 그대로예요. 반대 변환은 static_cast나 명시적 캐스트가 필요하며 원래 포인터 값을 산출해요.

int n = 1;
int* p1 = &n;
void* pv = p1;
int* p2 = static_cast<int*>(pv);
std::cout << *p2 << '\n'; // prints 1

원래 포인터가 어떤 다형적 타입 객체 안의 베이스 클래스 하위 객체를 가리키고 있으면, dynamic_cast로 가장 파생 타입(most derived type)의 완전 객체를 가리키는 void*를 얻을 수 있어요.

void 포인터는 char 포인터와 같은 크기·표현·정렬을 가져요.

void 포인터는 알 수 없는 타입의 객체를 전달하는 데 쓰여요. C 인터페이스에서 흔하죠. std::mallocvoid*를 반환하고, std::qsort는 두 const void* 인자를 받는 사용자 제공 콜백을 기대하며, pthread_createvoid*를 받고 반환하는 사용자 제공 콜백을 기대해요. 모든 경우에 사용하기 전에 올바른 타입으로 캐스팅하는 것은 호출자의 책임이에요.

함수 포인터 (Pointers to functions)

함수 포인터는 비멤버 함수나 정적 멤버 함수의 주소로 초기화할 수 있어요. 함수→포인터 암시 변환 덕분에 주소 연산자는 생략 가능해요.

void f(int);
void (*p1)(int) = &f;
void (*p2)(int) = f; // same as &f

함수나 함수 참조와 달리, 함수 포인터는 객체라서 배열에 저장하거나 복사·할당할 수 있어요.

void (a[10])(int);  // Error: array of functions
void (&a[10])(int); // Error: array of references
void (*a[10])(int); // OK: array of pointers to functions

함수 포인터를 포함한 선언은 타입 별칭으로 자주 단순화할 수 있어요.

using F = void(int); // named type alias to simplify declarations
F a[10];  // Error: array of functions
F& a[10]; // Error: array of references
F* a[10]; // OK: array of pointers to functions

함수 포인터는 함수 호출 연산자의 왼쪽 피연산자로 쓸 수 있고, 그러면 가리키는 함수를 호출해요.

int f(int n)
{
    std::cout << n << '\n';
    return n * n;
}

int main()
{
    int (*p)(int) = f;
    int x = p(7);
}

함수 포인터를 역참조하면 가리키는 함수를 식별하는 lvalue가 나와요.

int f();
int (*p)() = f;  // pointer p is pointing to f
int (&r)() = *p; // the lvalue that identifies f is bound to a reference
r();             // function f invoked through lvalue reference
(*p)();          // function f invoked through the function lvalue
p();             // function f invoked directly through the pointer

함수 포인터는 함수·함수 템플릿 특수화·함수 템플릿을 포함하는 오버로드 집합으로 초기화할 수 있어요. 단 오직 하나의 오버로드만 포인터 타입과 일치해야 해요(오버로드된 함수의 주소 참고).

template<typename T>
T f(T n) { return n; }

double f(double n) { return n; }

int main()
{
    int (*p)(int) = f; // instantiates and selects f<int>
}

함수 포인터에는 동등 비교 연산자가 정의돼 있어요(같은 함수를 가리키면 같게 비교).

멤버 포인터 (Pointers to members)

데이터 멤버 포인터 (Pointers to data members)

클래스 C의 멤버인 비정적 멤버 객체 m에 대한 포인터는 정확히 &C::m 표현식으로 초기화할 수 있어요. &(C::m)이나 C의 멤버 함수 안에서 &m 같은 표현식은 멤버 포인터를 만들지 않아요.

그런 포인터는 멤버 포인터 접근 연산자 operator.*operator->*의 오른쪽 피연산자로 쓸 수 있어요.

struct C { int m; };

int main()
{
    int C::* p = &C::m;          // pointer to data member m of class C
    C c = {7};
    std::cout << c.*p << '\n';   // prints 7
    C* cp = &c;
    cp->m = 10;
    std::cout << cp->*p << '\n'; // prints 10
}

접근 가능하고 모호하지 않은 비가상 베이스 클래스의 데이터 멤버 포인터는, 같은 데이터 멤버를 가리키는 파생 클래스 포인터로 암시 변환할 수 있어요.

struct Base { int m; };
struct Derived : Base {};

int main()
{
    int Base::* bp = &Base::m;
    int Derived::* dp = bp;
    Derived d;
    d.m = 1;
    std::cout << d.*dp << ' ' << d.*bp << '\n'; // prints 1 1
}

반대 방향 변환, 즉 파생 클래스의 데이터 멤버 포인터에서 모호하지 않은 비가상 베이스 클래스의 데이터 멤버 포인터로는, 베이스 클래스에 그 멤버가 없어도 static_cast와 명시적 캐스트로 허용돼요(포인터로 접근할 때 가장 파생 클래스가 그 멤버를 가질 경우).

struct Base {};
struct Derived : Base { int m; };

int main()
{
    int Derived::* dp = &Derived::m;
    int Base::* bp = static_cast<int Base::*>(dp);

    Derived d;
    d.m = 7;
    std::cout << d.*bp << '\n'; // okay: prints 7

    Base b;
    std::cout << b.*bp << '\n'; // undefined behavior
}

멤버 포인터를 가리키는 타입은 그 자체가 멤버 포인터일 수 있어요. 멤버 포인터는 다단계(multilevel)일 수 있고 각 단계에서 다르게 cv-qualified될 수 있어요. 포인터와 멤버 포인터를 혼합한 다단계 조합도 허용돼요.

struct A
{
    int m;
    // const pointer to non-const member
    int A::* const p;
};

int main()
{
    // non-const pointer to data member which is a const pointer to non-const member
    int A::* const A::* p1 = &A::p;

    const A a = {1, &A::m};
    std::cout << a.*(a.*p1) << '\n'; // prints 1

    // regular non-const pointer to a const pointer-to-member
    int A::* const* p2 = &a.p;
    std::cout << a.**p2 << '\n'; // prints 1
}

멤버 함수 포인터 (Pointers to member functions)

클래스 C의 멤버인 비정적 멤버 함수 f에 대한 포인터는 정확히 &C::f 표현식으로 초기화할 수 있어요. &(C::f)이나 C의 멤버 함수 안에서 &f 같은 표현식은 멤버 함수 포인터를 만들지 않아요.

그런 포인터는 멤버 포인터 접근 연산자 operator.*operator->*의 오른쪽 피연산자로 쓸 수 있어요. 결과 표현식은 함수 호출 연산자의 왼쪽 피연산자로만 쓸 수 있어요.

struct C
{
    void f(int n) { std::cout << n << '\n'; }
};

int main()
{
    void (C::* p)(int) = &C::f; // pointer to member function f of class C
    C c;
    (c.*p)(1);                  // prints 1
    C* cp = &c;
    (cp->*p)(2);                // prints 2
}

베이스 클래스의 멤버 함수 포인터는 같은 멤버 함수를 가리키는 파생 클래스 포인터로 암시 변환할 수 있어요.

struct Base
{
    void f(int n) { std::cout << n << '\n'; }
};
struct Derived : Base {};

int main()
{
    void (Base::* bp)(int) = &Base::f;
    void (Derived::* dp)(int) = bp;
    Derived d;
    (d.*dp)(1);
    (d.*bp)(2);
}

반대 방향 변환, 즉 파생 클래스의 멤버 함수 포인터에서 모호하지 않은 비가상 베이스 클래스의 멤버 함수 포인터로는, 베이스 클래스에 그 멤버 함수가 없어도 static_cast와 명시적 캐스트로 허용돼요.

struct Base {};
struct Derived : Base
{
    void f(int n) { std::cout << n << '\n'; }
};

int main()
{
    void (Derived::* dp)(int) = &Derived::f;
    void (Base::* bp)(int) = static_cast<void (Base::*)(int)>(dp);

    Derived d;
    (d.*bp)(1); // okay: prints 1

    Base b;
    (b.*bp)(2); // undefined behavior
}

멤버 함수 포인터는 콜백이나 함수 객체로 쓸 수 있어요. 흔히 std::mem_fn이나 std::bind를 적용한 뒤에요.

#include <algorithm>
#include <cstddef>
#include <functional>
#include <iostream>
#include <string>
#include <vector>

int main()
{
    std::vector<std::string> v = {"a", "ab", "abc"};
    std::vector<std::size_t> l;
    transform(v.begin(), v.end(), std::back_inserter(l),
              std::mem_fn(&std::string::size));
    for (std::size_t n : l)
        std::cout << n << ' ';
    std::cout << '\n';
}

출력:

1 2 3

널 포인터 (Null pointers)

모든 타입의 포인터는 그 타입의 널 포인터 값(null pointer value)이라는 특별한 값을 가져요. 값이 널인 포인터는 객체나 함수를 가리키지 않아요(널 포인터 역참조는 undefined behavior) 그리고 값이 널인 같은 타입의 모든 포인터와 같게 비교돼요.

널 포인터 상수(null pointer constant)는 포인터를 널로 초기화하거나 기존 포인터에 널 값을 할당하는 데 쓰여요. 다음 중 하나예요.

  • 값이 0인 정수 리터럴,
  • std::nullptr_t 타입의 prvalue(보통 nullptr). (since C++11)

매크로 NULL도 쓸 수 있는데, 구현 정의된 널 포인터 상수로 확장돼요.

0-초기화(zero-initialization)와 값 초기화(value-initialization)도 포인터를 널 값으로 초기화해요.

널 포인터는 객체의 부재를 나타내는 데 쓸 수 있어요(예: std::function::target()), 또는 다른 오류 조건 표시로도 써요(예: dynamic_cast). 일반적으로 포인터 인자를 받는 함수는 값이 널인지 거의 항상 검사하고 그 경우를 다르게 처리해야 해요(예를 들어 delete 표현식은 널 포인터가 전달되면 아무것도 하지 않아요).

무효 포인터 (Invalid pointers)

포인터 값 p는 평가 e의 문맥에서 다음 조건 중 하나를 만족하면 유효해요.

  • p는 널 포인터 값,
  • p는 함수에 대한 포인터,
  • p는 객체 o에 대한 포인터 또는 그 끝을 지나는 포인터이고, eo의 저장 영역의 지속 기간 안에 있음.

포인터 값 p가 평가 e에서 쓰이는데 e의 문맥에서 유효하지 않으면:

  • e가 간접 참조이거나 해제 함수 호출이면 undefined behavior,
  • 그 외에는 구현 정의 동작.
int* f()
{
    int obj;
    int* local_ptr = new (&obj) int;

    *local_ptr = 1; // OK, the evaluation "*local_ptr" is
                    // in the storage duration of "obj"

    return local_ptr;
}

int* ptr = f();  // the storage duration of "obj" is expired,
                 // therefore "ptr" is an invalid pointer in the following contexts

int* copy = ptr; // implementation-defined behavior
*ptr = 2;        // undefined behavior: indirection of an invalid pointer
delete ptr;      // undefined behavior: deallocating storage from an invalid pointer

const (Constness)

  • 포인터 선언에서 cv* 앞에 오면, 그것은 선언 지정자 시퀀스의 일부로서 가리키는 객체에 적용돼요.
  • 포인터 선언에서 cv* 뒤에 오면, 그것은 declarator의 일부로서 선언되는 포인터에 적용돼요.
문법 의미
const T* 상수 객체를 가리키는 포인터
T const* 상수 객체를 가리키는 포인터
T* const 객체를 가리키는 상수 포인터
const T* const 상수 객체를 가리키는 상수 포인터
T const* const 상수 객체를 가리키는 상수 포인터
// pc is a non-const pointer to const int
// cpc is a const pointer to const int
// ppc is a non-const pointer to non-const pointer to const int
const int ci = 10, *pc = &ci, *const cpc = pc, **ppc;
// p is a non-const pointer to non-const int
// cp is a const pointer to non-const int
int i, *p, *const cp = &i;

i = ci;    // okay: value of const int copied into non-const int
*cp = ci;  // okay: non-const int (pointed-to by const pointer) can be changed
pc++;      // okay: non-const pointer (to const int) can be changed
pc = cpc;  // okay: non-const pointer (to const int) can be changed
pc = p;    // okay: non-const pointer (to const int) can be changed
ppc = &pc; // okay: address of pointer to const int is pointer to pointer to const int

ci = 1;    // error: const int cannot be changed
ci++;      // error: const int cannot be changed
*pc = 2;   // error: pointed-to const int cannot be changed
cp = &ci;  // error: const pointer (to non-const int) cannot be changed
cpc++;     // error: const pointer (to const int) cannot be changed
p = pc;    // error: pointer to non-const int cannot point to const int
ppc = &p;  // error: pointer to pointer to const int cannot point to
           // pointer to non-const int

일반적으로 한 다단계 포인터에서 다른 다단계 포인터로의 암시 변환은 qualification conversions에서 설명한 규칙을 따라요.

복합 포인터 타입 (Composite pointer type)

비교 연산자의 피연산자나 조건 연산자의 두 번째·세 번째 피연산자가 포인터 또는 멤버 포인터이면, 그 피연산자들의 공통 타입으로 복합 포인터 타입(composite pointer type)을 정해요.

타입 T1, T2를 가진 두 피연산자 p1, p2가 주어졌을 때, p1p2는 다음 조건 중 하나를 만족할 때만 복합 포인터 타입을 가질 수 있어요.

  • p1p2가 모두 포인터.
  • p1, p2 중 하나는 포인터이고 다른 하나는 널 포인터 상수.
  • p1, p2가 모두 널 포인터 상수이고 T1, T2 중 적어도 하나는 비정수 타입. (since C++11) (until C++14)
  • T1, T2 중 적어도 하나가 포인터 타입, 멤버 포인터 타입 또는 std::nullptr_t. (since C++14)

p1, p2의 복합 포인터 타입 C는 다음과 같이 정해져요.

  • p1이 널 포인터 상수면 CT2. (until C++11)
  • p2가 널 포인터 상수면 CT1. (until C++11)
  • p1, p2가 모두 널 포인터 상수면 Cstd::nullptr_t. (since C++11)
  • 그 외, p1이 널 포인터 상수면 CT2. (since C++11)
  • 그 외, p2가 널 포인터 상수면 CT1. (since C++11)
  • 그 외, 다음 조건을 모두 만족하면:
    • T1 또는 T2가 "cv1 void에 대한 포인터",
    • 다른 타입이 "cv2 T에 대한 포인터"(여기서 T는 객체 타입 또는 void),
    • C는 "cv12 void에 대한 포인터"(여기서 cv12cv1cv2의 합집합).
  • 그 외, 다음 조건을 모두 만족하면:
    • T1 또는 T2가 "함수 타입 F1에 대한 포인터",
    • 다른 타입이 "noexcept 함수 타입 F2에 대한 포인터",
    • F1F2noexcept를 제외하고 같음,
    • C는 "F1에 대한 포인터". (since C++17)
  • 그 외, 다음 조건을 모두 만족하면:
    • T1이 "C1에 대한 포인터",
    • T2가 "C2에 대한 포인터",
    • C1C2 중 하나가 다른 하나에 reference-related,
    • C는, C1C2에 reference-related면 T1T2의 qualification-combined type, C2C1에 reference-related면 T2T1의 qualification-combined type.
  • 그 외, 다음 조건을 모두 만족하면:
    • T1 또는 T2가 "클래스 C1의 함수 타입 F1에 대한 멤버 포인터",
    • 다른 타입이 "클래스 C2의 noexcept 함수 타입 F2에 대한 멤버 포인터",
    • C1, C2 중 하나가 다른 것에 reference-related,
    • F1F2noexcept를 제외하고 같음,
    • C는, C1C2에 reference-related면 "C2의 타입 F1에 대한 멤버 포인터", C2C1에 reference-related면 "C1의 타입 F1에 대한 멤버 포인터". (since C++17)
  • 그 외, 다음 조건을 모두 만족하면:
    • T1이 "클래스 C1의 비함수 타입 M1에 대한 멤버 포인터",
    • T2가 "클래스 C2의 비함수 타입 M2에 대한 멤버 포인터",
    • M1M2가 최상위 cv-qualification을 제외하고 같음,
    • C1, C2 중 하나가 다른 것에 reference-related,
    • C는, C1C2에 reference-related면 T2T1의 qualification-combined type, C2C1에 reference-related면 T1T2의 qualification-combined type.
  • 그 외, T1T2가 유사(similar) 타입이면 CT1T2의 qualification-combined type.
  • 그 외, p1p2는 복합 포인터 타입을 갖지 않으며, 그런 C의 결정을 요구하는 프로그램은 ill-formed예요.
using p = void*;
using q = const int*;
// The determination of the composite pointer type of "p" and "q"
// falls into the ["pointer to cv1 void" and "pointer to cv2 T"] case:
// cv1 = empty, cv2 = const, cv12 = const
// substitute "cv12 = const" into "pointer to cv12 void":
// the composite pointer type is "const void*"

using pi = int**;
using pci = const int**;
// The determination of the composite pointer type of "pi" and "pci"
// falls into the [pointers to similar types "C1" and "C2"] case:
// C1 = int*, C2 = const int*
// they are reference-related types (in both direction) because they are similar
// the composite pointer type is the qualification-combined type
// of "p1" and "pc1" (or that of "pci" and "pi"): "const int**"

결함 보고서 (Defect reports)

이전에 발표된 C++ 표준에 소급 적용된, 동작을 바꾸는 결함 보고서는 다음과 같아요.

  • CWG 73 (C++98): 객체 포인터는 배열의 끝을 지나는 포인터와 결코 같게 비교되지 않음 → 널·비함수 포인터는 나타내는 주소를 비교하도록 함.
  • CWG 903 (C++98): 0으로 평가되는 정수 상수 표현식이면 무엇이든 널 포인터 상수였음 → 값 0인 정수 리터럴로 제한.
  • CWG 1438 (C++98): 무효 포인터 값을 어떤 방식으로든 쓰는 것이 undefined였음 → 간접 참조와 해제 함수 전달은 undefined, 그 외는 구현 정의.
  • CWG 1512(N3624) (C++98): 복합 포인터 타입 규칙이 불완전해 int**const int** 비교를 허용하지 않았음 → 완성.
  • CWG 2206 (C++98): void에 대한 포인터와 함수에 대한 포인터가 복합 포인터 타입을 가졌음 → 그런 타입을 갖지 않음.
  • CWG 2381 (C++17): 복합 포인터 타입을 결정할 때 함수 포인터 변환이 허용되지 않았음 → 허용.
  • CWG 2822 (C++98): 저장 영역의 지속 기간 끝에 도달하면 포인터 값이 무효화될 수 있었음 → 포인터 유효성은 평가 문맥에 기반.
  • CWG 2933 (C++98): 함수 포인터는 항상 무효였음 → 항상 유효.

더 알아보기