공부/C || C++

C+ 11 스마트 포인터(unique_ptr, shared_ptr, weak_ptr)

sudo 2021. 7. 7. 02:11

C++는 JAVA와 달리 가비지 컬렉터가 없기 때문에 new로 메모리 할당을 하고 사용이 끝나면 사용자가 반드시 delete로 해제해줘야 한다. 하지만 스마트 포인터는 포인터처럼 동작하는 클래스 템플릿으로써, 할당 후 메모리 해제를 해주지 않아도 자동으로 해제해주는 기능을 가진 녀석이다. 더 정확하게 이야기하면 스마트 포인터라는 클래스 템플릿의 소멸자에는 사용한 자원을 해제해주는 코드가 포함되어 있다고 한다. 이러면 자연스럽게 메모리 할당 후 해제를 해주지 않아서 생기는 memory leak도 어느 정도 해결될 것이라고 생각한다. 신기하게 new로 할당한 메모리도 스마트 포인터에 대입하면 나중에 해제해주지 않아도 된다고 한다. 스마트 포인터에는 unique_ptr, shared_ptr, weak_ptr 이 세가지가 있다.

 

1. unique_ptr

unique_ptr는 하나의 스마트 포인터만 객체를 가리키도록 허용하는 것이다. 그러니까 unique_ptr이 어떤 객체를 가리키고 있는 것을 포인터가 그 객체를 소유했다고 표현하면 unique_ptr은 소유한 객체가 다른 스마트 포인터들이 소유하지 못하게 한다. 소유권을 복사할 수도 없고(대입 연산자 사용 불가), 소유권을 move() 멤버함수를 통해 이전할 수만 있다. 그리고 소유권을 가지고 있을때만 소멸자가 객체를 삭제할 수 있다.

 

C++ 14부터는 make_unique() 함수를 통해 unique_ptr을 안전하게 만들 수 있다고 한다.

class Circle{
	public:
		int m_tRadius;

		Circle() {}
		Circle(int r) { m_tRadius = r; }
		~Circle() {}
		
		int getRadius() {
			return m_tRadius;
		}
};


int main()
{
	unique_ptr<Circle> c1 = make_unique<Circle>(33);
	cout << c1->getRadius() << endl;
	auto c2 = move(c1);
	cout << c2->getRadius() << endl;
}

결과는

위에서 언급한 것 처럼 delete를 안해줘도 문제가 생기지 않는다. c1은 스택에 들어가있는 포인터 변수이기 때문에 main함수가 끝나면 스택에서 pop됨과 동시에 소멸자가 호출돼서 할당된 resource가 해제된다. unique_ptr가 나옴으로써 같은 객체를 서로 다른 포인터가 가리키고 있다가 한 포인터가 메모리 해제 한 줄 모르고 다른 포인터가 또 해제하려고 한다던가(double free) 이미 해제한 메모리에 다른 포인터가 접근하려고 한다던가(illegal memory access) 하는 문제가 방지될 것이다. 

 

unique_ptr은 가리키는 객체를 혼자 소유하고 있다는 것들 목표로 하기 때문에, 어떤 함수가 인자로 unique_ptr이 가리키고 있는 객체 타입을 받는다고 하더라도 그냥 raw 포인터로 받아야 한다. 코드 출처는 https://modoocode.com/229 여기다.

class A {
  int* data;

 public:
  A() {
    std::cout << "자원을 획득함!" << std::endl;
    data = new int[100];
  }

  void some() { std::cout << "일반 포인터와 동일하게 사용가능!" << std::endl; }

  void do_sth(int a) {
    std::cout << "무언가를 한다!" << std::endl;
    data[0] = a;
  }

  ~A() {
    std::cout << "자원을 해제함!" << std::endl;
    delete[] data;
  }
};

// 올바르지 않은 전달 방식
// std::unique_ptr<A>& ptr -> A* ptr 이렇게 바꿔야한다
void do_something(std::unique_ptr<A>& ptr) { ptr->do_sth(3); } 

int main() {
  std::unique_ptr<A> pa(new A());
  do_something(pa);
}

do_something에서 저렇게 unique_ptr인 객체 포인터를 인자로 받으면 main내에서 pa가 객체를 소유할뿐만 아니라 do_something 내에서도 ptr이 그 객체를 소유하고 사용하는 것이기 때문에 이렇게 쓰면 컴파일은 되지만 unique_ptr의 목적과 맞지 않다고 한다. 따라서 아래와 같이 do_something은 A* ptr로 인자를 받아야 한다. 또한 main에서도 get()함수를 통해 raw pointer를 넘겨줘야 한다. 왜냐하면 do_something도 더 이상 unique_ptr이 아닌 raw pointer를 인자로 받고 있으니까.

class A {
  int* data;

 public:
  A() {
    std::cout << "자원을 획득함!" << std::endl;
    data = new int[100];
  }

  void some() { std::cout << "일반 포인터와 동일하게 사용가능!" << std::endl; }

  void do_sth(int a) {
    std::cout << "무언가를 한다!" << std::endl;
    data[0] = a;
  }

  ~A() {
    std::cout << "자원을 해제함!" << std::endl;
    delete[] data;
  }
};
// 올바른 파라미터
void do_something(A* ptr) { ptr->do_sth(3); }

int main() {
  std::unique_ptr<A> pa(new A());
  // 올바른 argument
  do_something(pa.get());
}

 

마지막으로 unique_ptr을 vector와 같은 container에 넣을 때도 조심해야 한다. 저번 push_back과 emplace_back을 비교한 글(https://welikecse.tistory.com/2)을 보면 push_back에게 넘겨준 인자가 rvalue이고 move 생성자가 정의되어 있으면 move 생성자와 소멸자가(move 생성자가 없거나 넘겨준 인자가 rvalue가 아니면 복사 생성자와 소멸자 호출) emplace_back에 비해 각각 한번씩 더 호출됐다는 점을 확인할 수 있었다(그래서 push_back이 이론상 더 비효율적이라는 말 까지 했었다). 왜냐하면 생성자로 만든 객체를 push_back 내부에서 만든 또 다른 객체로 복사(또는 move)시키는 방식으로 집어 넣기 때문이다. 하지만 문제는 unique_ptr은 복사 생성자가 없다. 당연히 복사 되면 안되기 때문이다.

 

C++ vector::push_back vs vector::emplace_back

지난 게시글인 Rvalue reference관해서 공부하다가 우연히 보게 된 건데 C++에 container들을 자주 써왔다고 생각했는데 push_back과 emplace_back에 performance 차이가 있다는 이야기는 처음 들어봤다. 사실 emp..

welikecse.tistory.com

그럼 어떻게 vector같은 container에 넣을 수 있을까? push_back vs emplace_back 글에서 봤듯이 push_back은 두개의 함수가 오버로딩 되어있다.

void push_back (const value_type& val);
void push_back (value_type&& val);

 

그리고 unique_ptr은 std::move()를 위한 move 생성자와 move 대입 연산자가 이미 존재한다(std::move() 함수를 사용하면 lvalue를 rvalue로 바꿔줄 수 있다는걸 이미 공부했다). 그러면 push_back인자로 넘겨줄 녀석을 std::move()를 이용해서 rvalue로 바꿔서 넘겨주면 자연스럽게 두번째 push_back함수가 호출될 것이고 그러면 rvalue reference 글에서도 봤듯이, move 생성자가 호출 될 것이다. 복사 생성자를 안써도 되니 문제가 없다.

int main() {
	std::vector<std::unique_ptr<A>> vec;
	std::unique_ptr<A> pa(new A(1));

	vec.push_back(std::move(pa));  // 잘 실행됨
}

 

그런데 emplace_back은 std::move()를 쓰지 않아도 잘 된다. 

int main() {
  std::vector<std::unique_ptr<A>> vec;

  // vec.push_back(std::unique_ptr<A>(new A(1))); 과 동일
  vec.emplace_back(new A(1));

  vec.back()->some();
}

push_back vs emplace_back글에서도 언급했듯이, emplace_back은 객체 생성에 필요한 인자만 넘겨줘도 잘 동작한다고 했다(그리고 그렇게 쓰는게 효율적이라고 했다. emplace_back인자로 std::unique_ptr<A>(new A(1)) 이렇게 줘버리면 빌드가 되긴 되는데 걍 push_back이랑 다를바 없어서 비효율적). 여기서도 마찬가지로 std::unique_ptr<A>를 생성하는데 필요한 인자인 new A(1)만 넘겨 주는게 가능하고 그렇게 하면 emplace_back 내부에서 객체를 만들것이기 때문에 복사생성자가 불리지 않아서 문제 없이 vector에 넣어줄 수 있다.

 

2. shared_ptr

shared_ptr은 unique_ptr과 달리 한개의 객체를 여러개의 포인터가 가리킬 수 있다. 이렇게 참조하고 있는 포인터의 갯수를 reference count라고 하는데 reference count는 use_count()를 통해 확인할 수 있다. use_count()는 내가 가리키고 있는 객체를 나를 포함해서 몇개의 shared_ptr이 가리키고 있는지 알려준다. shared_ptr는 make_shared로 만들 수 있다.

int main() {
	shared_ptr<int> ptr1 = make_shared<int>(5);
	cout << ptr1.use_count() << endl;//1
	
	auto ptr2 = ptr1;
	std::shared_ptr<int> ptr3(ptr2);
	cout << ptr1.use_count() << endl; // 3
	cout << ptr2.use_count() << endl; // 3
	cout << ptr3.use_count() << endl; // 3
}

shared_ptr의 동작 원리는 아래 그림처럼 shared_ptr은 객체를 가리킴과 동시에 control block에 reference count 정보를 따로 둔다. 

 

 

 

다만, shared_ptr에서 주의해야 할 점이 있는데 그건 shared_ptr의 인자로 객체의 주소를 줄때다(코드 출처:https://modoocode.com/252)

class A {
	int *data;

public:
	A() {
		data = new int[100];
		std::cout << "자원을 획득함!" << std::endl;
	}

	~A() {
		std::cout << "소멸자 호출!" << std::endl;
		delete[] data;
	}
};

int main() {
	A* a = new A();
	std::shared_ptr<A> pa1(a);
	std::shared_ptr<A> pa2(a);
}

이렇게 되면 pa1과 pa2는 각자 다른 control block을 할당해서 각자 따로 reference count를 유지한다.

사진 출처:https://modoocode.com/252

이렇게 되면 만약에 p1이 가리키는 객체를 delete로 해제하면 p2는 여전히 reference count를 1로 가진채 해제된 메모리를 가리키고 있을테니 문제가 생긴다. 또한 main함수가 종료되면 p1, p2 둘다 소멸자를 호출해서 double free문제가 생긴다. 따라서 주솟값을 shared_ptr 인자로 주는것은 하지 않아야 한다. 그런데 다음과 같이 어쩔 수 없이 객체 자신을 가리키는 shared_ptr을 리턴하는 함수로 다른 shared_ptr을 초기화 해야하는 상황이 생길 수도 있다. 

#include <iostream>
#include <memory>

class A {
  int *data;

 public:
  A() {
    data = new int[100];
    std::cout << "자원을 획득함!" << std::endl;
  }

  ~A() {
    std::cout << "소멸자 호출!" << std::endl;
    delete[] data;
  }

  std::shared_ptr<A> get_shared_ptr() { return std::shared_ptr<A>(this); }
};

int main() {
  std::shared_ptr<A> pa1 = std::make_shared<A>();
  std::shared_ptr<A> pa2 = pa1->get_shared_ptr();

  std::cout << pa1.use_count() << std::endl;
  std::cout << pa2.use_count() << std::endl;
}

이런 경우에 당연히 이전에 언급한 double free 문제가 생기는데 이때는 다음과 같이 enable_shared_from_this<>로 상속받고 shared_from_this() 멤버함수를 이용하면 된다고 한다.

class A : public std::enable_shared_from_this<A> {
  int *data;

 public:
  A() {
    data = new int[100];
    std::cout << "자원을 획득함!" << std::endl;
  }

  ~A() {
    std::cout << "소멸자 호출!" << std::endl;
    delete[] data;
  }

  std::shared_ptr<A> get_shared_ptr() { return shared_from_this(); }
};

int main() {
  std::shared_ptr<A> pa1 = std::make_shared<A>();
  std::shared_ptr<A> pa2 = pa1->get_shared_ptr();

  std::cout << pa1.use_count() << std::endl;
  std::cout << pa2.use_count() << std::endl;
}

 

shared_ptr에도 주의해야할 점이 하나 더 있는데 그게 바로 순환 참조다.

class A {
	int *data;
	std::shared_ptr<A> other;
public:
	A() {
		data = new int[100];
		std::cout << "자원을 획득함!" << std::endl;
	}

	~A() {
		std::cout << "소멸자 호출!" << std::endl;
		delete[] data;
	}

	std::shared_ptr<A> get_shared_ptr() { return std::shared_ptr<A>(this); }
	void set_other(std::shared_ptr<A> o) { other = o; }
};

int main() {
	std::shared_ptr<A> pa = std::make_shared<A>();
	std::shared_ptr<A> pb = std::make_shared<A>();

	std::cout << pa.use_count() << std::endl; // 1
	std::cout << pb.use_count() << std::endl; // 1

	pa->set_other(pb);
	pb->set_other(pa);

	std::cout << pa.use_count() << std::endl; // 2
	std::cout << pb.use_count() << std::endl; // 2
}

위와 같은 경우에 pa는 생성됨과 동시에 class A객체를 가리키고 pb도 또 다른 class A객체를 가리킨다. 그리고 set_other로 pa가 가리키는 객체안의 멤버 변수(other)은 pb가 가리키던 객체를 가리키고, pb가 가리키는 객체안의 멤버 변수(other)로 pa가 가리키던 객체를 가리킨다. 말로 하니까 복잡한데 그림으로 그리면 다음과 같다.

그러니까 main이 끝나서 pa가 가리키는 객체를 해제하고 pa의 reference count를 0으로 만들고 싶은데 그러려면 첫번째 객체(왼쪽 객체)가 가진 멤버 변수인 other이 가리키는 메모리 영역을 해제 해야하고 그게 두번째 객체(오른쪽 객체)이다. 그런데 두번째 객체의 매모리를 해제하려 하니까 멤버로 가진 other이 가리키는 메모리 영역을 해제 해야 하고 그게 또 첫번째 객체(왼쪽 객체)다. 이렇게 무슨 운영체제에서 데드락 걸린거 마냥 이러지도 저러지도 못하는 상황을 순환 참조(circular reference)라고 말하고 이걸 해결하기 위해 weak_ptr이 나왔다.

 

shared_ptr을 사용해서 circular reference에 걸리는 경우를 하나 더 살펴보자(코드출처: http://sweeper.egloos.com/2826435

class User;
typedef shared_ptr<User> UserPtr;
 
class Party
{
public:
    Party() {}
    ~Party() { m_MemberList.clear(); }
 
public:
    void AddMember(const UserPtr& member)
    {
        m_MemberList.push_back(member);
    }
 
private:
    typedef vector<UserPtr> MemberList;
    MemberList m_MemberList;
};
typedef shared_ptr<Party> PartyPtr;
 
class User
{
public:
    void SetParty(const PartyPtr& party)
    {
        m_Party = party;
    }
 
private:
    PartyPtr m_Party;
};
 
 
int _tmain(int argc, _TCHAR* argv[])
{
    PartyPtr party(new Party);
 
    for (int i = 0; i < 5; i++)
    {
        // 이 user는 이 스코프 안에서 소멸되지만,
        // 아래 party->AddMember로 인해 이 스코프가 종료되어도 user의 refCount = 1
        UserPtr user(new User);
 
        // 아래 과정에서 순환 참조가 발생한다.
        party->AddMember(user);
        user->SetParty(party);
    }
 
    // 여기에서 party.reset을 수행해 보지만,
    // 5명의 파티원이 party 객체를 물고 있어 아직 refCount = 5 의 상태
    // 따라서, party가 소멸되지 못하고, party의 vector에 저장된 user 객체들 역시 소멸되지 못한다.
    party.reset();
 
    return 0;
}

그림으로 그려보면 아래같은 상황이다

party.reset()을 함으로써 party객체의 멤버 변수인 m_MemberList를 정리하려고 한다. m_MemberList안에는 User객체를 가리키는 shared_ptr이 있고 이 User객체들은 멤버로 party객체를 가리키는 shared_ptr을 갖고 있다. party를 해제하려고 하니 m_MemberList안에 포인터들이 가리키는 User객체들을 해제해야하고, User객체들을 해제하려고 하니 다시 party를 해제해야 하는 순환 참조가 일어나고 있다. 이 순환 참조를 weak_ptr을 이용해서 해결해보자.

 

3. weak_ptr

위와 같은 상황을 해결하고자 weak_ptr이 나왔다. weak_ptr은 shared_ptr을 가리킬 뿐이고, 그렇게 shared_ptr을 통해서 간접적으로 객체를 가리키는 것으로 보인다. weak_ptr은 shared_ptr과 다르게 객체의 수명에 관여하지 않는다. 이게 무슨 말이냐 하면 shared_ptr은 reference count가 0이되면 가리키는 객체를 해제하는데 weak_ptr은 reference count를 바꿀 수 없다. 다만 weak_ptr가 어떤 shared_ptr를 가리키고 있고 shared_ptr가 가리키고 있는 객체가 있다면 weak_ptr에서 shared_ptr로 변환한 다음에 참조할뿐, weak_ptr이 shared_ptr을 가리킨다고 해서 reference count가 변하지는 않는다는 의미다. 

 

weak_ptr을 shared_ptr로 변환하려면 lock 함수가 필요하다(코드 출처: http://egloos.zum.com/sweeper/v/3059940)

shared_ptr<_Ty> lock() const
{      
    // convert to shared_ptr
    return (shared_ptr<_Elem>(*this, false));
}

그리고 expired함수는 weak_ptr이 어떤 shared_ptr을 가리키고 있고 있을 때, 이 shared_ptr이 가리키고 있는 객체가 존재 하는지, 즉 그 shared_ptr의 reference가 0이 아니면 false, 0이면 true를 반환하는 함수다.

 

weak_ptr을 이용해서 shared_ptr의 마지막 circular reference 예시를 해결한다면 아래와 같다

class User;
typedef shared_ptr<User> UserPtr;
 
class Party
{
public:
    Party() {}
    ~Party() { m_MemberList.clear(); }
 
public:
    void AddMember(const UserPtr& member)
    {
        m_MemberList.push_back(member);
    }
 
    void RemoveMember()
    {
        // 제거 코드
    }
 
private:
    typedef vector<UserPtr> MemberList;
    MemberList m_MemberList;
};
typedef shared_ptr<Party> PartyPtr;
typedef weak_ptr<Party> PartyWeakPtr;
 
class User
{
public:
    ~User() { LeaveParty(); }
    
    void SetParty(const PartyPtr& party)
    {
        m_Party = party;
    }
 
    void LeaveParty()
    {
        if (!m_Party.expired())
        {
            // shared_ptr로 convert 한 뒤, 파티에서 제거
            // 만약, Party 클래스의 RemoveMember가 이 User에 대해 먼저 수행되었으면,
            // m_Party는 expired 상태
            PartyPtr partyPtr = m_Party.lock();
            if (partyPtr)
            {
                partyPtr->RemoveMember();
            }
        }  
    }
 
private:
    // PartyPtr m_Party;
    PartyWeakPtr m_Party;    // weak_ptr을 사용함으로써, 상호 참조 회피
};
 
 
int main()
{
    // strong refCount = 1;
    PartyPtr party(new Party);
 
    for (int i = 0; i < 5; i++)
    {
        // 이 UserPtr user는 이 스코프 안에서 소멸되지만,
        // 아래 party->AddMember로 인해 이 스코프가 종료되어도 user의 refCount = 1
        UserPtr user(new User);
         
        party->AddMember(user);
     
        // weak_ptr로 참조하기에 party의 strong refCount = 1
        user->SetParty(party);
    }
    // for 루프 이후 strong refCount = 1, weak refCount = 5
 
    party.reset();
 
    return 0;
}

여기서 strong reference count는 shared_ptr가 객체를 가리킬 때 마다 1씩 증가하는(우리가 이전에 shared_ptr에서 배운 그 reference count)이고, weak reference count는 weak_ptr이 shared_ptr을 가리킬 때 마다 증가하는 reference count이다. weak reference count는 0이 된다고 객체가 파괴되지 않는다.

 

party.reset()을 함으로써 party가 가리키는 객체를 파괴하기 위해 Party class 소멸자가 호출되고, party가 가리키는 객체의 멤버인 m_MemberList안에 존재하는 shared_ptr은 User class를 가리키고 있는 녀석들(main에 user객체)이다. 따라서 이 user객체를 파괴하기 위해 User class의 소멸자가 호출되고 User class 소멸자 안에서는 LeaveParty()를 호출한다. LeaveParty는 우선 expired 함수를 통해 멤버 변수인 m_Party가 가리키고 있는 shared_ptr가 해제 되지 않은 객체를 가리키고 있는지 확인하고나서 lock함수를 통해 weak_ptr을 shared_ptr로 변환해서 객체에 접근한다. Party는 m_MemberList안에 User 객체들을 shared_ptr로 가리키고 있지만, User는 Party 객체를 weak_ptr로 가리키고 있기 때문에 서로를 가리키는 상황에서도 잘 해제가 되어서 프로그램이 끝난다.

 

아직까지 어떤 상황에서 raw pointer가 아닌 스마트 포인트를 써야할지 라던가, 스마트 포인터중에 어떤 스마트 포인터를 써야할지 명확하게 알진 못하겠지만 앞으로 의도적으로라도 쓰려고 하면서 익숙해지도록 노력해봐야겠다.

 

 

Reference

http://tcpschool.com/cpp/cpp_template_smartPointer

 

코딩교육 티씨피스쿨

4차산업혁명, 코딩교육, 소프트웨어교육, 코딩기초, SW코딩, 기초코딩부터 자바 파이썬 등

tcpschool.com

https://modoocode.com/229

 

씹어먹는 C ++ - <13 - 1. 객체의 유일한 소유권 - unique_ptr>

 

modoocode.com

https://modoocode.com/252

 

씹어먹는 C ++ - <13 - 2. 자원을 공유할 때 - shared_ptr 와 weak_ptr>

 

modoocode.com

http://egloos.zum.com/sweeper/v/3059940

 

[TR1] weak_ptr

1. shared_ptr shared_ptr의 내용은 다음 링크를 참고하기 바라며, 특히 3-9 Circular reference 챕터를 자세히 읽어보기 바란다. (위 링크엔 shared_ptr의 circular reference에 대한 예제가 포함되어 있다) 2. weak_ptr sh

egloos.zum.com