포인터

공부용·2025년 3월 17일


사진은 강아지 품종 "포인터입니다."

원시 포인터 (Raw Pointer)

가장 기본적인 포인터로, 직접 메모리 주소를 저장하고 조작할 수 있는 포인터

  • 변수 또는 객체의 메모리 주소를 저장한다.
  • '*'(역참조 연산자)를 사용하여 포인터가 가리키는 값을 읽거나 수정할 수 있다.
  • 명시적인 메모리 해제(delete)가 필요하다.
  • 잘못된 사용 시 댕글링 포인터(Dangling Pointer), 메모리 누수(Memory Leak)등의 문제가 발생할 수 있다.

원시 포인터의 장점

  • 직접 메모리 제어 가능 -> 속도가 빠르고 유연성이 높음
  • new를 사용하여 동적 메모리 할당 가능

원시 포인터 단점

  • 메모리 누수 위험
  • 댕글링 포인터 문제
  • 이중 해제 위험

메모리 누수

#include <iostream>

void memoryLeak() {
    int* ptr = new int(10); // 동적 할당
    std::cout << "메모리 누수 발생 가능: " << *ptr << std::endl;
    // delete ptr; // 해제를 하지 않으면 메모리 누수 발생
}

int main() {
    memoryLeak();
    return 0;
}

메모리 누수가 발생하면 메모리 부족이 발생할 수 있다.


스마트 포인터

c++에서 적용된 메모리 관리를 자동화하는 포인터. RAII 원칙을 따르며,
new와 delete를 직접 사용하지 않고 객체의 생명 주기를 자동으로 관리한다.

  • 기존 원시 포인터는 new로 할당한 메모리를 delete로 직접 해제해야하는데, 이 과정에서 메모리 누수(Memory Leak), 댕글링 포인터(Dangling Pointer), 이중 해제(Double Free) 등의 문제가 발생할 수 있다.

#include <iostream>
#include <memory>

// unique_ptr 사용 예제
void uniquePointerExample() {
    std::unique_ptr<int> ptr = std::make_unique<int>(100);
    std::cout << "unique_ptr 값: " << *ptr << std::endl;
} // ptr이 범위를 벗어나면 자동으로 메모리 해제

// shared_ptr 사용 예제 (참조 카운트 확인)
void sharedPointerExample() {
    std::shared_ptr<int> ptr1 = std::make_shared<int>(200);
    std::shared_ptr<int> ptr2 = ptr1; // 같은 메모리를 공유

    std::cout << "shared_ptr 값: " << *ptr1 << ", 참조 카운트: " << ptr1.use_count() << std::endl;
} // 마지막 shared_ptr이 범위를 벗어나면 메모리 해제

// weak_ptr 사용 예제 (순환 참조 방지)
void weakPointerExample() {
    std::shared_ptr<int> sharedPtr = std::make_shared<int>(300);
    std::weak_ptr<int> weakPtr = sharedPtr; // shared_ptr을 참조하지만 소유권 없음

    if (auto locked = weakPtr.lock()) { // weak_ptr을 shared_ptr로 변환 (유효한 경우만)
        std::cout << "weak_ptr 값: " << *locked << std::endl;
    } else {
        std::cout << "weak_ptr이 가리키는 객체가 이미 삭제됨" << std::endl;
    }
}

int main() {
    std::cout << "=== unique_ptr 예제 ===" << std::endl;
    uniquePointerExample();

    std::cout << "\n=== shared_ptr 예제 ===" << std::endl;
    sharedPointerExample();

    std::cout << "\n=== weak_ptr 예제 ===" << std::endl;
    weakPointerExample();

    return 0;
}

  1. unique_ptr (단일 소유권, 독점적 관리)
  • 단독 소유권을 가짐 (하나의 unique_ptr만 해당 객체를 소유 가능)
  • 소유권을 다른 unique_ptr로 이동가능 (std::move)
  • 범위를 벗어나면 자동으로 메모리 해제됨.
  1. shared_ptr (공유 소유권, 참조 카운트)
  • 여러 shared_ptr이 같은 객체를 공유 가능
  • use_count()를 사용하여 현재 참조 카운트 확인.
  • 마지막 shared_ptr이 소멸될 때 자동으로 메모리 해제
  1. weak_ptr (약한 참조, 순환 참조 방지)
  • shared_ptr을 참조하지만 참조 카운트에 영향을 주지 않음
  • 순환 참조 방지에 사용됨
  • lock()을 사용해 유효한 경우만 접근하여 순환 참조 방지.

순환 참조

순환 참조(Circular Reference)는 두 개 이상의 객체가 서로를 참조하면서, 참조 카운트가 0이 되지 않아 메모리가 해제되지 않는 문제이다.
C++에서는 std::shared_ptr만 사용하면 발생할 수 있으며, std::weak_ptr로 해결할 수 있다.

RAII (Resource Acquisition Is Initialization) 원칙을 지킴

객체가 생성될 때 자원을 획등하고, 객체가 소멸될 때 자동으로 자원을 해제하는 기법


참조(Reference) 방식

변수나 객체의 주소를 직접 조작하지 않고, 해당 변수를 가리키는 별칭(Alias)를 제공하는 방식
C++의 참조(&)나 Java/Python의 참조(Reference)는 실제 메모리 주소를 다루지 않으면서도 포인터처럼 객체를 공유할 수 있다.


#include <iostream>

void referenceExperiment() {
    int a = 10;
    int& ref = a;  // 참조 변수 (alias)

    std::cout << "원래 변수 a: " << a << std::endl;
    std::cout << "참조 변수 ref: " << ref << std::endl;

    ref = 20;  // 참조를 통해 원본 값 변경
    std::cout << "참조를 변경한 후 a: " << a << std::endl;
}

void pointerExperiment() {
    int b = 30;
    int* ptr = &b;  // 포인터 사용

    std::cout << "원래 변수 b: " << b << std::endl;
    std::cout << "포인터 ptr이 가리키는 값: " << *ptr << std::endl;

    *ptr = 40;  // 포인터를 통해 원본 값 변경
    std::cout << "포인터를 사용하여 변경한 후 b: " << b << std::endl;
}

int main() {
    std::cout << "=== 참조 방식 실험 ===" << std::endl;
    referenceExperiment();

    std::cout << "\n=== 포인터 방식 실험 ===" << std::endl;
    pointerExperiment();

    return 0;
}

이렇듯 참조와 포인터는 약간의 차이가 있다.

포인터와 차이점

참조(&)와 포인터(*) 모두 변수의 주소를 다루지만, 사용법과 동작 방식에서 차이가 있다.

참조는 직접 변수처럼 사용 가능하다

#include <iostream>

int main() {
    int a = 10;
    int& ref = a;  // 참조 변수 (alias)

    ref = 20;  // a의 값도 변경됨
    std::cout << "a: " << a << ", ref: " << ref << std::endl;

    return 0;
}

참조는 기존 변수의 별칭일 뿐이므로 ref를 변경하면 a도 변경됨
다른 변수를 참조할 수 없으며(재할당 불가능) 항상 유효한 변수를 가리켜야 함(nullptr 불가능).

#include <iostream>

int main() {
    int a = 10;
    int b = 30;
    int* ptr = &a;  // a의 주소를 저장

    *ptr = 20;  // a의 값이 20으로 변경됨
    ptr = &b;   // ptr이 b를 가리키도록 변경 가능
    *ptr = 40;  // b의 값이 40으로 변경됨

    std::cout << "a: " << a << ", b: " << b << std::endl;

    return 0;
}

포인터는 다른 변수를 가리키도록 변경할 수 있고 *ptr을 통해 역참조해야 실제 값을 변경 가능하다.


댕글링(Dangling)

더 이상 유효하지 않은 메모리 주소를 가리키는 포인터
다음과 같은 상황에서 발생한다.

  1. 해제된 메모리를 참조할 때
void danglingPointer() {
    int* ptr = new int(42);
    delete ptr; // 메모리 해제
    std::cout << "댕글링 포인터 접근: " << *ptr << std::endl; // 정의되지 않은 동작
}

int main() {
    danglingPointer();
    return 0;
}

정상적인 메모리 위치를 가리키는 경우

이미 해제된 메모리 위치를 가리키는 경우

해제된 메모리에서 쓰레기 값을 가져오게 된다.

  1. 스코프 종료 후 지역 변수의 주소를 반환할 때
#include <stdio.h>
#include <stdlib.h>

// 비정상적인 경우: 함수 종료 후 지역 변수의 주소를 반환 -> 댕글링 포인터 발생
int* danglingFunction() {
    int x = 10; // 지역 변수
    return &x;  // 함수 종료 후 x가 사라짐 -> 댕글링 포인터
}

// 정상적인 경우: 동적 메모리 할당을 사용하여 올바르게 반환
int* validFunction() {
    int* x = (int*)malloc(sizeof(int)); // 동적 메모리 할당
    if (x == NULL) {
        printf("메모리 할당 실패\\n");
        return NULL;
    }
    *x = 10;
    return x;  // 올바른 메모리 반환 (사용 후 free 필요)
}

int main() {
    int* ptr1 = danglingFunction();
    
    // 스택을 덮어쓰기 위해 다른 변수를 추가
    int y = 999;
    printf("새로운 변수 y 할당: %d \n", y);

    printf("비정상적인 경우 (댕글링 포인터): %d \n", *ptr1); // 댕글링 포인터 접근 (예측 불가한 결과)

    int* ptr2 = validFunction();
    if (ptr2 != NULL) {
        printf("정상적인 경우: %d \n", *ptr2); // 정상적인 값 출력
        free(ptr2); // 동적 메모리 해제
    }

    return 0;
}

함수 종료 이후에 함수의 지역 변수는 사라지기 때문에 지역 변수의 포인터를 보면 쓰레기 값이 들어있다.

  1. 할당된 메모리를 여러 포인터가 가리킬 때
int* ptr1 = new int(5);
int* ptr2 = ptr1;
delete ptr1; // ptr1이 가리키는 메모리 해제
*ptr2 = 10;  // ptr2는 댕글링 포인터가 됨

이렇듯 댕글링은 유효하지 않은 메모리를 참조하는 포인터나 참조(Reference)에서 발생하는 문제이다


안전한 포인터

#include <iostream>

int main() {
    int* ptr = new int(10);
    delete ptr;  // 메모리 해제
    ptr = nullptr;  // 안전한 처리

    if (ptr == nullptr) {
        // 안전한 접근이 필요한 코드
    }

    return 0;
}

이런식으로 해제한 포인터를 nullptr 처리를 하게 되면 다음과 같은 장점이 있다.

  • 댕글링 포인터 방지
  • 이중 해제 방지 (nullptr이 들어가 있는 포인터를 다시 해제해도 문제 없음, 컴파일러가 무시함)
  • 명확한 타입 (nullptr은 void*가 아님, nullptr_t 타입임)
  • 함수 오버로딩에서 명확한 타입 구분 가능
#include <iostream>

int main() {
    int* ptr = nullptr;  // nullptr로 초기화된 포인터
    std::cout << *ptr << std::endl;  //  오류 발생 (Segmentation Fault)
    return 0;
}

nullptr에 접근하게 되면 이런식으로 세그먼트 오류가 발생하여 문제를 알아차리기 쉽다.

Null은 정수로 0으로 표현되기 때문에 컴파일러가 Null문자를 정상인 정수로 인식할 가능성이 있다. 따라서 nullptr을 사용하는것이 안전하다.

profile
공부 내용을 가볍게 적어놓는 블로그.

0개의 댓글