태크놀로지

[Effective C++] 객체 기반 방식의 자원관리 본문

C++

[Effective C++] 객체 기반 방식의 자원관리

원택 2020. 12. 2. 17:14

자원(resource)이란, 사용을 일단 마치고 난 후엔 시스템에 돌려주어야 하는 모든 것을 말한다.

※ 자원을 가져와서 다 썼으면, 무조건 해제해주어야 한다.

 

이번 포스팅 내용

객체 기반 방식의 C++가 지원하는 생성자, 소멸자, 객체 복사함수를 사용하여 자원관리 하는것을 다루겠다.

 

1. 객체를 사용하여 자원을 관리하자

class Investment{
public:
	Investment() {
		cout << "Create" << endl;
	}
	~Investment() {
		cout << "Delete" << endl;
	}

	int data = 5;
};

Investment* CreateInvestment()
{
	return new Investment;
}

int main()
{
	// 객체를 삭제하기전, return으로 도중하차 할경우, 메모리 누수 발생
	Investment* pInv = CreateInvestment();
	// ... return이 있을경우?
	delete pInv;
}

팩토리 함수를 사용해서 객체를 생성할 경우, 객체를 삭제하기전, return으로 도중하차하면, 메모리 릭 발생

 

대책 : 객체를 사용하여 소멸자에서 자원해제를 맡자

// 함수로 얻어낸 자원이 항상 해제하도록 만들 방법은, 
// 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡도록 한다.
auto_ptr<Investment> pInv(CreateInvestment());

 

자원관리에 객체를 사용하는 방법의 중요한 두가지 특징

1. 자원을 획득한 후에 자원관리객체에게 넘긴다.

  • RAII(Resource Acquisition Is Initialization) : 자원 획득 즉, 초기화
  • 자원 획득과 자원관리객체의 초기화가 한문장에서 이루어진다.
  • 자원을 획득하고 나서 바로 자원관리객체에 넘겨준다.

2. 자원관리객체는 자신의 소멸자를 사용해서 자원이 확실히 해제되도록 한다.

  • 소멸자 : 어떤 객체가 소멸될때 자동으로 호출
  • 함수 블록을 벗어나면, 무조건 멤버객체는 소멸자가 불린다.

auto_ptr

// 복사를 할경우, 복사생성자, 복사대입연산자를 통해 원본 객체는 null로 만든다.
// 복사하는 객체만이 자원의 유일한 소유권을 갖는다고 가정
auto_ptr<Investment> pInv2(pInv); // pInv는 null, pInv2가 자원의 소유권을 갖고 있음.
  • 자신이 소멸될때 자신이 가리키고 있는 대상에 대해 자동으로 delete를 먹임
  • 복사를 할경우, 복사생성자, 복사대입연산자를 통해 원본 객체는 null로 만든다.
    • 복사하는 객체만이 자원의 유일한 소유권을 갖는다고 가정
  • 상식적인 복사동작이 아니기 때문에, STL컨테이너 경우 auto_ptr를 원소로 허용하지 않는다.
    • STL컨테이너 경우 정상적인 복사가 일어나야하기 때문

auto_ptr를 사용할 수 없는 상황이라면, shared_ptr(RCSP)

shared_ptr<Investment> pInv(CreateInvestment());
shared_ptr<Investment> pInv2(pInv);

// 형식적인 복사가 일어남.
cout << pInv->data << endl;
cout << pInv2->data << endl;
  • 대안으로 참조 카운팅 방식 스마트 포인터 RCSP(reference-counting smart pointer)를 사용하자
  • RCSP : 외부 객체의 개수를 유지하고 있다가, 그 개수가 0이 되면 해당 자원을 자동으로 삭제하는 스마트 포인터

주요 내용 정리

  • 자원을 관리하는 객체를 사용해서, 자원을 관리하자.
  • auto_ptr와 shared_ptr는 소멸자 내부에서 delete를 사용한다. 
    • delete[]를 사용하지 않는다. (동적할당한 배열에 사용했을 경우, 메모리 누수 발생)

※ CreateInvestment()에서 Investment*를 반환하고 있는데, 이 또한 자원관리객체에 넣어주어야하기 때문에 실수를 할 수 있다. 반환값을 shared_ptr로 반환하는것이 좋다. (나중 설계부분에서 다룸)

 

2. 자원 관리 클래스의 복사동작에 대해 고찰해보자.

자원 관리 클래스의 주축인, 자원 획득 및 초기화(RAII)를 공부했다.

  • 힙 기반 자원에 대해 auto ptr, shared ptr 를 사용

하지만 힙 이외에 자원들은, 자원 관리 클래스를 직접 만들어주어야 한다.

  • 힙 이외에 자원들은 auto_ptr, shared ptr를 사용하기엔 맞지 않다.

 

예제 : Mutex 객체를 관리하는 예제코드 - 참조카운팅을 사용하여 복사기능 추가

void lock(mutex* pm)
{
	pm->lock();
}
void unlock(mutex* pm)
{
	pm->unlock();
}

class Lock {
public:
	// shared_ptr이 소멸될때, mutex 객체가 삭제되면 안된다.
	// deleter 대신 unlock함수를 호출하도록 생성자에서 설정
	explicit Lock(mutex* pm) : m_pMutex(pm, unlock){  
		lock(m_pMutex.get()); // get() : 명시적(explicit) 변환
		cout << "create" << endl;
	}
	~Lock() {
		// m_pLock이 소멸되면서, 자동으로 설정해놓은 unlock함수 호출
		cout << "delete" << endl;
	}

private:
	//mutex* m_pMutex;

	/* 객체 복사의 경우 */
	// 1. 복사 금지, 복사 함수 = delete
	// 2. 참조카운팅을 사용하여, 복사기능추가
	shared_ptr<mutex> m_pMutex; 
};

mutex m;

int main() 
{
	// Critical Section
	{
		// RAII
		Lock m1(&m);
		// 이럴 경우엔?
		Lock m2(m1);
	}
}

RAII를 사용하여 객체 생성 및 초기화, 자동 소멸을 관리한다.  근데 복사를 하게 될 경우 어떻게 해야할까?

  1. 복사를 금지한다. (delete 복사함수)
  2. 관리하고 있는 자원에 대해 참조 카운팅을 수행한다. (shared ptr)
  3. 관리하고 있는 자원을 진짜로 복사한다. (깊은복사)
  4. 관리하고 있는 자원의 소유권을 옮긴다. (auto ptr)

3. 자원관리클래스에서 관리되는 자원을 외부에서 접근 시 explicit / implicit

class Investiment
{
public:
	int data;
};

Investiment* CreateInvestiment()
{
	return new Investiment;
}

int daysHeld(const Investiment* pi)
{
	return 1;
}

int main()
{
	shared_ptr<Investiment> pInv(CreateInvestiment());

	// int days(daysHeld(pInv)); // error
	// 위 에러와 같이 우리는 자원관리 객체로부터 Investment*를 얻고싶다.
	// int days(daysHeld(pInv.get()));
	
	int invData = pInv.get()->data; // explicit : 명시적
	int invData = pInv->data; // implicit : 암시적
}

get() 함수를 통해 Investiment*를 얻음으로서 explicit 변환이 일어나고 있다. 하지만 operator->()를 사용하면 implicit 변환이 일어나는것을 주의하자.

 

explicit, implicit

class Player {
private:
	string name;
	int id;

public:
	Player(string _name);
	Player(int _id);

	string GetString() const { return name; }
};
Player::Player(string _name) : name(_name), id(-1)
{
}
Player::Player(int _id) : id(_id), name("")
{
}

void DoSomethingStr(Player p)
{
	// 플레이어 이름을 사용한 작업,,,
	cout << p.GetString() << endl;
}

int main()
{
	DoSomethingStr(Player("p1"));

	// 함수 파라미터에서 컴파일러가 자동으로 implicit 변환
	string name = "p1";
	DoSomethingStr(name);

	// 컴파일러가 자동으로 Player(int)로 암시적 변환을 해주어서 컴파일 에러가 발생하지 않음.
	DoSomethingStr(3);
}

DoSomethingStr()은 플레이어 이름을 사용한 작업을 수행하는 함수라고 가정한다. 함수인자는 Player이지만 위에 보다시피 string값과 심지어 int값을 넣어도 컴파일에러가 발생하지 않고있다.

컴파일러는 함수 파라미터에 대한 해결법을 찾기위해 검색하는 도중 Player(int)생성자를 발견하고, 암시적으로 Player(int)로 변환하여 인자를 전달하고 있다. 이러한 위험성을 낮추기 위해서 생성자에 explicit을 사용하면 컴파일러의 암시적(implicit)변환을 막아준다.

 

※ 암시적(implicit)변환으로 좀 더 편리한 코드작성을 제공해줄수는 있겠지만, 대부분 코드의 안전성을 위해서 명시적(explicit) 변환 함수를 제공하는 쪽이 나을때가 많다.

4. new와 delete를 사용할때는 반드시 형식을 갖추자.

메모리 배치구조

  • 한개의 객체 : object
  • 객체의 배열 : n(개수) - object - object - object ,,,

※ 배열이 만들어지는 힙 메모리에는 배열원소의 개수가 박혀 들어간다.

 

5. new로 생성한 객체를 스마트포인터에 저장하는 코드는 별도 한 문장으로 만들자.

int priority();
void processWidget(shared_ptr<Widget> pw, int priority);

processWidget(shared_ptr<Widget>(new Widget), priority());

함수 첫번째 인자에서 두 부분으로 나누어져있다.

  1. new Widget
  2. shared_ptr 생성자 호출부분

컴파일러는 processWidget함수 호출이 이루어지기 전에, 세가지 연산을 위한 코드를 만들어야한다.

  1. priority() 호출
  2. new Widget
  3. shared ptr 생성자 호출

하지만 컴파일러 제작사마다 연산 실행순서가 다르다.

newWidget -> priority() -> shared ptr 생성자 호출

위 순서로 진행될때, priority()에서 예외가 발생하면, 메모리릭이 발생한다.

 

new로 생성한 객체를 스마트 포인터로 넣는 코드는 별도의 한 문장으로 만들자

shared_ptr<Widget> pw(new Widget);
processWidget(pw, priority());

 

(1-5) 정리

  • 자원 누출을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 해제하는 RAII 객체를 사용하자
  • 일반적으로 널리 쓰이는 RAII 클래스는 shared ptr / auto ptr이다.
    • shared ptr / auto ptr은 delete를 호출하므로 동적할당배열 사용 X
  • 자원관리클래스에서 상황에 맞게 복사생성자를 작성하자.
    • 일반적으로 복사금지나, 참조 카운팅을 해주는선으로 마무리
  • 단일객체 : delete / 객체의 배열 : delete[]
  • new로 생성한 객체를 스마트 포인터로 넣는 코드는 별도의 한 문장으로 만들자
    • 컴파일러마다 호출순서가 다르므로 디버깅하기 힘든 메모리릭이 발생할 수 있음

출처

effective c++ / 스콧 마이어스