[Advanced C++] 27. 포인터, 주소 & 역참조 연산자, 포인터 vs 참조, nullptr, const 포인터, pass by address, 포인터&, std::nullptr_t

dev.kelvin·2025년 3월 17일
1

Advanced C++

목록 보기
27/74
post-thumbnail

1. Pointer

포인터

	int i{ };

위 코드가 실행되면 변수 i의 값을 저장하기 위해 메모리 상에 int type의 크기만큼 공간이 할당된다

이때 i를 사용할 때 마다 해당 메모리 주소에 접근하여 값을 가져오거나 수정하게 되는것이다

C++에서 메모리 주소를 직접 이용하여 값에 접근하고 조작하려면 포인터, 주소를 직접 다루지 않고 간접적으로 접근하려면 참조를 사용한다 (변수에 대한 별칭)

주소 연산자 &

변수의 메모리 주소를 알고싶다면 &를 사용한다, 보통 메모리 주소는 16진수로 표현한다

	int i{ 10 };
    &i; //주소 ex) 0027FAA0

&는 문맥에 다라 다양하게 사용이 된다

	int& a; //ref
    &a; //주소
    x & y; //bit연산 AND

역참조 연산자 *

변수의 주소만 가지고는 딱히 사용할 곳이 없어보이는데 이 주소를 이용하여 메모리 상의 원본값에 접근하려면 역참조 연산자인 *를 사용한다

	int i{ 10 };
    *(&i); //변수 i의 메모리 주소에 저장된 값을 가져온다

포인터

결론적으로 포인터란 변수의 주소를 담는 변수이다, 이 변수로 주소를 담고 역참조 연산자를 통해 메모리 주소상에 있는 데이터에 접근 및 조작이 가능하다

	int a{ 10 };
	int* ptrA{ &a };

타입 옆에 *를 붙여 포인터 변수를 만들 수 있다

기본적으로 포인터 변수는 초기화 되지 않는다, 이렇게 초기화 되지 않은 포인터를 wild pointer라고 하며 이러한 포인터를 사용할 경우 Undefined Behavior가 발생할 수 있다

항상 포인터 변수는 초기화하여 사용하는것을 권장한다

위에 정리한 역참조 연산자를 이용하여 포인터에 담긴 주소에 접근하여 원본 데이터에 접근이 가능하다

	*ptrA; //10

포인터, 참조의 차이

  1. 포인터는 메모리 주소를 저장하고 참조는 그렇지않다
  2. 포인터는 런타임에 가리키는 대상 변경이 가능하지만 참조는 그렇지 않다
	int a{ 10 };
	int b{ 20 };

	int& refA{ a };
	--a;
	refA = b;

	std::cout << a << std::endl; //20이 나온다, refA는 a를 참조하고 있는데 b를 할당했기 때문에 a원본값이 b로 변경된다 (refA가 b를 참조하게 되는게 아니다)
  1. 포인터는 null을 가질 수 있지만 참조는 가질 수 없다
  2. 포인터는 반드시 초기화 할 필요는 없지만 참조는 초기화가 필수이다 (물론 포인터도 초기화를 매우 권장)

nullptr

포인터는 아무것도 가리키지 않을 수 있다 (null value)

여기서 null이란 아무것도 가리키지 않는다는 특별한 의미의 값이다, 포인터에서는 nullptr을 사용하여 null value를 넣을 수 있다

즉 처음에 아무것도 가리키지 않는 초기화 상태에서는 nullptr로 초기화하고 나중에 런타임에서 다른 주소를 가리키게 할 수 있다

	int* ptrA{ nullptr };
    int a{ 10 };
    
    ptrA = &a;

C++프로그램에서 가장 크래시를 많이 발생시킨다고 해도 과언이 아닌 원인을 바로 nullptr 참조라고 생각한다

nullptr을 역참조하게 되면 무조건 crash가 발생한다

	int* ptr{ nullptr };
    *ptr; //crash!

nullptr은 아무것도 가리키지 않는다는 의미이고 이를 역참조 한다는건 아무런 값도 가져올 수 없다는 의미이기 때문에 crash가 발생한다

그렇다면 이러한 nullptr은 어떻게 확인할까?

직관적으로 if와 ==을 이용하여 확인하는 방법과 그냥 if()의 조건문에 넣는 방법이 있다 (같은 의미)

	if (ptr == nullptr)
    {
    }
    
    if (ptr)
    {
    }

포인터는 nullptr일때 false를 반환하고 그렇지 않을때 true를 반환한다 -> 그렇기 때문에 조건문에 사용이 가능하다

하지만 if()에서 위와같이 nullptr을 체크하면 단순히 nullptr 체크는 가능하지만 Dangling 포인터는 확인이 불가능하다 (nullptr은 아니지만 유효하지 않은 메모리 주소를 가리키는 포인터 확인은 불가능함)

(UE에는 IsValid()를 통해 UObject* Dangling pointer 체크가 가능하다)

프로그래밍을 할 때 항상 nullptr을 체크하는 습관을 들여야 한다, 그리고 early return을 시켜 depth를 줄이는 방식도 좋은 방식이라고 생각한다

	if (!ptr)
    {
    	return;
	}
    
    ptr->a;

const 포인터

일반적인 포인터 변수는 다른 주소를 가리킬 수 있고, 포인터가 가리키는 주소의 값을 변경할 수 있다

이러한 수정이나 가리키는 대상 변경을 원하지 않는다면 const로 사용하는것이 좋다

추가로 일반적인 포인터 변수는 const 변수의 주소를 가리킬 수 없다

	const int i{ 10 };
    int* ptrI{ &i }; //error

포인터가 가리키는 주소의 값을 변경할 수 있기 때문에 const제한이 깨지기 때문이다

이럴때는 const 포인터를 사용하면 된다

	const int i{ 10 };
    const int* ptrI{ &i }; //OK
    
    *ptrI = 100; //error

const 포인터 변수는 주소의 값을 변경할 수 없다

하지만 const가 앞에 오면 가리키는 주소는 변경이 가능하다

    const int i{ 10 };
    const int i2{ 20 };
    const int* ptrI{ &i };

    ptrI = &i2; //OK

포인터 변수는 타입 뒤에 const가 올 수도 있다, 이렇게하면 도중에 가리키는 주소 변경이 불가능하다
하지만 주소의 값은 변경이 가능하다

	int i{ 10 };
    int i2{ 20 };
    int* const ptri{ &i };
    
    ptri = &i2; //error
    *ptri = 100; //OK

이러한 const는 앞 뒤 둘 다 가능하다 (가리키는 주소 변경X, 주소의 값 변경X)

	const int* const ptri{ &i };

2. pass by address

주소 전달 방식

함수 인자 전달 방식으로 값 전달, 참조 전달에 대해 정리했는데 주소 전달 방식도 존재한다

값 전달 방식은 인자로 넘긴 값의 사본이 생성되어 전달되며 참조 전달은 사본이 생성되지 않고 원본 그대로를 참조한다
(따라서 비용적 측면에서 참조 전달이 좋다)

주소 전달 방식도 참조 전달 방식과 마찬가지로 사본이 생성되지 않아 성능상 유리하고 원본값을 수정할 수 있다
(객체 자체가 아닌 객체의 주소를 전달하는 방식이다)

	#include <string>
    
    void Foo(const std::string* InString)
    {
    	if(!InString) //nullptr check & early return
        {
        	return;
        }
        
    	std::cout << *InString << '\n';
    }
    
    int main()
    {
    	std::string teststring{ "Kelvin" };
        Foo(&teststring); //주소 전달
    }

매개변수를 const 포인터로 사용하지 않는다면 역참조를 이용하여 원본값 변경이 가능하다, 단 참조와 마찬가지로 변경이 필요하지 않다면 const로 안전하게 전달하는것이 좋다

주소 전달, 참조 전달

둘 다 사본을 생성하지 않아 값 전달에 비해 성능상 유리하고 원본 데이터 수정이 가능하다는 공통점이 있다

참조 전달은 nullptr 체크가 필요 없지만 주소 전달 시 nullptr 체크는 꼭 해주는게 좋다

주소 전달 방식을 함수의 인자로 사용하게 되면 default value를 사용할 수 있다

	void Foo(const int* ptr = nullptr);
    
    Foo(); //default nullptr

포인터 매개변수의 주소 변경

결론적으로 포인터 매개변수의 주소를 변경해도 원본 ptr에는 영향을 주지 않는다

	void Foo(int* InPtr)
    {
    	InPtr = nullptr;
    }
    
    int main()
    {
    	int i{ 10 };
        int* ptrI{ &i };
        
        Foo(ptrI);
        
        //ptrI는 nullptr이 되지 않는다
    }

왜냐하면 매개변수인 InPtr은 ptrI의 주소값을 복사 받았기 때문이다, 따라서 원본 주소값 변경은 일어나지 않는다

이때 원본 ptr의 주소를 변경하고 싶다면 포인터를 참조로 받으면 된다

	void Foo(int*& refInPtr)
    {
    	refInPtr = nullptr;
    }
    
    int main()
    {
    	int i{ 10 };
        int* ptrI{ &i };
        
        Foo(ptrI);
        
        //ptrI는 nullptr이 됨
    }

nullptr 권장

포인터에서 null을 의미할 때 nullptr을 권장하는 이유가 있다

우선 타입 안정성때문이다

    void print(int x)
    {
        std::cout << "print(int): " << x << '\n';
    }

    void print(int* ptr)
    {
        std::cout << "print(int*): " << (ptr ? "non-null\n" : "null\n");
    }
    
    print(0); //int매개변수의 함수가 호출
    print(nullptr); //int* 매개변수의 함수가 호출
    print(NULL); //컴파일러에 따라 다르게 호출

null을 의미하려고 0을 넣었지만 함수 오버로딩에 의해 의도치 않은 함수 호출이 발생할 수 있기 때문이다

따라서 포인터에 null은 무조건 nullptr로 사용하는게 좋다

이러한 nullptr의 타입은 std::nullptr_t 정의된다

	#include <cstddef>
    
    std::nullptr_t TestNullptr = nullptr;
    void print(std::nullptr_t)
    {
        std::cout << "in print(std::nullptr_t)\n";
    }

    void print(int*)
    {
        std::cout << "in print(int*)\n";
    }

    int main()
    {
        print(nullptr); // print(std::nullptr_t) 호출

        int x { 5 };
        int* ptr { &x };

        print(ptr); // print(int*) 호출

        ptr = nullptr;
        print(ptr); // print(int*) 호출

        return 0;
    }
profile
GameDeveloper🎮 Dev C++, DataStructure, Algorithm, UE5, Assembly🛠, Git/Perforce🌏

0개의 댓글