C++ Basic

[C++ Basic] Pointer에 대하여

서론

C++에서 진짜 너무 많이 등장하는 개념이 바로 Pointer이다. 메모리의 주소를 관리하고~ 뭐시기~ 항상 등장하는데, 막상 지속적으로 사용하지 않으니 게속해서 까먹는? 대표적인 개념이라고 할 수 있다. 그렇기 때문에 이번에 포인터든 스마트포인터든 한번에 다 정리해보도록 하겠다.

Pointer

포인터는 그냥 메모리 주소를 담는 변수이다. int* p 는 p가 가르키는 곳에 int가 있다는 뜻이다.

변수 x       [주소 0x1000] 값: 5
포인터 p     [주소 0x2000] 값: 0x1000   ← p는 x의 주소를 저장
int x = 5;
int* p = &x;

void f(int* p)
{
  *p = 10;
}
  • 이렇게 하면 P의 값 자체는 바꿀 수 있다.

더블 포인터

포인터 자체를 바꾸고 싶을때 사용한다. 더블포인터는 포인터를 가르키는 포인터이다.

변수 x        [0x1000] 값: 5
포인터 p      [0x2000] 값: 0x1000   ← p는 x를 가리킴
더블포인터 pp  [0x3000] 값: 0x2000   ← pp는 p를 가리킴

int x = 5;
int* p = &x;    // p: int를 가리키는 포인터, p의 값은 x의 주소
int** pp = &p;  // pp: "int*를 가리키는 포인터", pp의 값은 p의 주소
void allocate(int** pp) {
    *pp = new int(42);   // 호출자의 p가 이제 새 메모리를 가리키게 됨
}

int* p = nullptr;
allocate(&p);
// 이제 p != nullptr, *p == 42

⚠️ 여기서 잠깐!

void makeNull(int* p) {
    p = nullptr;
}

int x = 5;
int* ptr = &x;
makeNull(ptr);
// ptr은 여기서 nullptr일까요, 여전히 &x일까요?
  • 정답은 여전히 &x이다. 왜냐하면 p는 ptr의 복사본이기 때문이다…!!
  • 만약에 *p = nullptr 이런식으로 가르키는거를 바꾸게 되면 원본이 바뀌므로 nullptr이 됐을 것이다.

트리플 포인터

똑같은 논리를 이용해서 더블포인터 자체를 바꿀 수 있다.

void createMatrix(int*** outMatrix, int rows, int cols) {
    *outMatrix = new int*[rows];             // 호출자의 matrix가 이 배열을 가리키게 함
    for (int i = 0; i < rows; i++)
        (*outMatrix)[i] = new int[cols];
}

int** matrix;
createMatrix(&matrix, 3, 4);
  • int** matrix라고 선언이 된 순간, matrix가 가르키는 곳에는 int*(주소값)이 반드시 들어있다는 뜻이다.

확인 문제

두 개의 int* 변수 p1, p2가 있을 때, 이 둘이 서로 가리키는 대상을 맞바꾸고 싶다고 해봅시다 (예: p1이 x를 가리키고 p2가 y를 가리켰다면, 함수 호출 후엔 p1이 y를, p2가 x를 가리키게).

void swapPointers( /* 여기 파라미터 타입은? */ ) {
    // 여기 구현은?
}

int main() {
    int x = 10, y = 20;
    int* p1 = &x;
    int* p2 = &y;
    swapPointers(p1, p2);  // 호출 후 p1은 y를, p2는 x를 가리켜야 함
}

(* 여기서 x와 y의 값은 그대로 두고 포인터가 가르키는걸 서로 바꿔야 하는거임)

추가: 포인터 변수의 상수화

int main() {
 int a = 2;
    const int *ptr = &a;
    
    a = 1; // 컴파일 통과
    *ptr = 10; // 오류
}
  • 이렇게 *ptr을 상수화를 하면 역참조 할때의 값을 바꿀 수 없게 된다.
int main() {
 int a = 2;
    int b = 1;
    int *const ptr = &a;
    
    a = 10; // 컴파일 통과
    ptr = &b; // 오류
}
  • 이렇게 하면 ptr이 다른 변수를 참조하게 바꿀 수 없게 된다.

기호 정리 및 레퍼런스

기호쓰이는 위치의미
*타입 선언 (int* p)“p는 포인터 타입이다”라고 선언
*표현식 (*p)역참조 — “p가 가리키는 곳으로 가서 그 값을 읽거나 써라”
&타입 선언 (int& r)“r은 레퍼런스 타입이다”라고 선언
&표현식 (&x)주소 연산자 — “x라는 변수의 주소를 알려줘”
  • 역참조는 이미 있는 포인터를 가지고 진행하게 된다.
int x = 10;
int& r = x;   // 여기 &는 주소 연산자가 아니라, "r은 int에 대한 레퍼런스다"라는 타입 선언!
  • 레퍼런스선은은 타입선언이다. (주소를 가르키는 &x와 다름!!)
  • 개념적으로는 “r이라는 이름은 이제부터 x라는 메모리 공간을 가르키는 또다른 이름이야”라고 선언하는 것.
x (별명: r)
┌────────┐
│   10   │  ← 주소는 하나뿐, 이름표만 x와 r 두 개
└────────┘
  • 레퍼런스랑 포인터는 그러면 뭐가 다르냐? ⇒ 재바인딩이 다르다.
  • 레퍼런스는 이름을 붙이는 것이기 때문에 다시 다른 대상을 가르키게 할 수 없다.
int x = 10, y = 20;

int* p = &x;
p = &y;        // OK! 이제 p는 y를 가리킴 (포인터는 재할당 가능)

int& r = x;
r = y;         // 이건 "r이 y를 가리키게 재바인딩"이 절대 아님!
               // 그냥 "r(=x)에 y의 값을 대입"하는 거라, x가 20이 됨. r은 여전히 x의 별명.
int x = 10;
int* p = &x;
int** pp = &p;
int*** ppp = &pp;

int***&  r0 = ppp;    // ppp 자체의 별명 (타입: int***)
int**&   r1 = *ppp;   // *ppp (=pp)의 별명 (타입: int**)
int*&    r2 = **ppp;  // **ppp (=p)의 별명 (타입: int*)
int&     r3 = ***ppp; // ***ppp (=x)의 별명 (타입: int, 진짜 값 10)

Smart Pointer

기존 pointer의 문제

void foo() {
    int* p = new int(10);
    // ... 여러 로직 ...
    if (someCondition) return;      // 여기서 return하면 delete를 못 함! → 메모리 누수
    // ... 예외가 발생할 수도 있음 ...
    delete p;
}
  • 기존의 포인터는 delete를 해서 메모리를 비워줘야 하는데 이거를 일일이 확인하는게 여간 귀찮고 어려운 작업이였음.
  • 이미 해제된 포인터를 다시 해제하는 경우도 있었음.

RAII (Resource Acquisition Is Initialization)

C++에는 “스택에 있는 객체는 스코프를 벗어나는 순간 소멸자가 호출된다”라는 강력한 보장이 있다. 이를 이용해서 소멸할때 삭제되게 만든 것이다.

template<typename T>
class SimpleSmartPtr {
    T* ptr;
public:
    SimpleSmartPtr(T* p) : ptr(p) {}
    ~SimpleSmartPtr() { delete ptr; }   // 스코프를 벗어나면 자동 호출!
    T& operator*() { return *ptr; }
    T* operator->() { return ptr; }
};
  • 핵심 개념은 raw pointer를 멤버변수로 두고, *와 →를 오버로딩해서 포인터처럼 사용하는 것이다.
  • 그리고 소멸자에 delete를 넣어서 스코프를 벗어나면 알아서 삭제되게 보장을 한다.

하지만 smart_pointer<int> sp 처럼 모든 걸 아우르는 템플릿은 없고 보통 3가지로 나뉘어서 사용한다.

unique_ptr – 독점 소유

가장 기본이 되는 스마트 포인터다. “이 자원은 딱 한곳에서만 소유할 수 있다”는 규칙을 강제한다.

unique_ptr<int> p1 = make_unique<int>(42);
// std::unique_ptr<int> p2 = p1;        // 컴파일 에러! 복사 금지
unique_ptr<int> p2 = move(p1); // OK: 소유권 이전, p1은 이제 nullptr
  • 복사 생성자, 대입 연산자 이런것들이 아예 삭제되어있어서 두 포인터가 가질 수 없다.
  • 그래서 넘길때는 move연산을 통해서 소유권 자체를 넘긴다.

shared_ptr – 공유 소유

  • 내부적으로 카운팅 변수를 둔다.
A* a = new A();
shared_ptr<A> pa1(a);
shared_ptr<A> pa2(a);
  • 이렇게 같은 원본에 대해서 만들게 되면 한개의 객체에 대해 2개의 shared_ptr이 생겨 카운팅이 제대로 되지 않는다.
A* a = new A();
auto pa1 = std::make_shared<A>();
auto pa2 = std::make_shared<A>();
auto pa3 = pa1;
  • 이렇게 해주면 make_shared를 통해서 2개의 객체와, 2개의 카운팅블록이 생겼고, pa3는 pa1과 같은 블록을 공유하게 되는 개념이다. 따라서 문제가 없이 동작하게 된다.

<특징>

  • unique_ptr과 달리 shared경우에는 자유롭게 복사할 수 있고, 마지막 소유자가 사라질 때 실제 메모리에서 해제된다.
  • 카운팅을 검사해야 하기 때문에 unique보다는 무거운 연산이다.

weak_ptr – 소유x, 관찰

  • 대표적으로 순환 참조 문제가 생기게 된다.
  • A와 B가 서로를 shared_ptr로 물고 있으면, 둘 다 참조 카운트가 절대로 0이 되지 않기 때문에 프로그램이 끝나도 메모리가 지워지지 않는 순환참조 문제가 발생하게 된다.
shared_ptr<Node> a = make_shared<Node>();
weak_ptr<Node> weakRef = a;   // 참조 카운트 증가 안 함

if (shared_ptr<Node> locked = weakRef.lock()) {
    // 객체가 아직 살아있으면 locked로 안전하게 접근
} else {
    // 이미 소멸된 뒤라면 locked는 nullptr
}
  • weak를 사용하면 가르키긴하지만 참조 카운팅을 하지 않는 포인터다.
  • 근데 이러면 역참조할때 문제가 생길 수 있는거 아닌가? 이미 지워져있을 수 있으니까 말이다.
  • 그래서 weak를 사용하면 역참조를 할 수 없고 lock()을 통해서 잠깐 shared_ptr로 바꿔야 한다는 점이 있다.
  • 참고로 그렇기 때문에 weak_ptr을 단독으로는 사용하지 못하고 shared_ptr을 구경(?) 하는 용도로 사용한다.

실제 예시

class Child;

class Parent {
public:
    vector<shared_ptr<Child>> children;   // 부모가 자식들을 소유
};

class Child {
public:
    weak_ptr<Parent> parent;   // 자식은 부모를 "약하게"만 참조

    void notifyParent() {
        if (auto p = parent.lock()) {   // 부모가 아직 살아있는지 확인 후 접근
            p->onChildEvent();
        }
    }
};
  • 부모와 자식 클래스가 있는데, 서로가 서로를 참조해야 하는 상황이 생길 수 있다.
  • 이 경우에, 자식은 부모를 약하게만 참조해서 카운팅을 늘리지 않는다.
class ResourceCache {
    unordered_map<string, weak_ptr<Resource>> cache;
public:
    shared_ptr<Resource> get(const string& key) 
    {
        if (auto it = cache.find(key); it != cache.end()) 
        {
            if (auto sp = it->second.lock()) 
            {
                return sp;   // 아직 살아있으면 캐시된 걸 재사용
            }
        }
        auto sp = make_shared<Resource>(key);
        cache[key] = sp;     // 캐시는 소유권 없이 그냥 지켜만 봄
        return sp;
    }
};
  • 캐시 패턴을 사용하는 경우도 대표적이다.
  • 키를 찾아서 존재하면 weak_ptr을 shared_ptr로 바꿔서 리턴한다.
  • 없으면 weak_ptr을 추가한다.

마무리

c++을 배우게 되면 pointer에 대해서 굉장히 자주 사용하고, 이거를 잘 사용해야 c++다운 코드를 작성할 수 있다. 가장 기본이 되는 부분에 대해서 학습을 했는데, 분명 나중 계속해서 까먹을 수 있기 때문에 자주와서 나 스스로도 복습을 해야할 것 같다.

lyh0309

View Comments

Recent Posts

[C++] Lock, Mutex, condition variable

Race Condition 서로 다른 Thread에서 같은 메모리를 공유할 때 같은 자원을 사용하면서 값이 이상하게 되는…

3 weeks ago

[C++ Basic] Move Semantics, Lvalue, Rvalue

서론 c++을 이용해서 개발을 하다보면 복사 대신에 이동연산을 통해서 여러 이점을 얻는다는 정보를 확인할 수…

4 weeks ago

[C++ Basic] Casting

서론 이번시간에는 Casting이라는 개념에 대해서 학습해 보도록 하겠다. 하지만 그 전에 Composition과 Aggregation의 차이에 대해서…

2 months ago

[C++ Basic] Reference, Template, Allocate

서론 c++ basic을 공부하기 위해서는 다른 언어들과의 기본적인 차이를 이해해야 한다. 그 차이는 바로 메모리…

2 months ago