STL 컨테이너와 객체 관리
STL 컨테이너가 객체를 다루는 방법은 객체를 직접 참조하는 것으로 객체를 처리할 때 항상 복사, 비교가 따라다닌다. 그런데, 일반적으로 프로그램을 설계할 때 크기가 큰 객체들이나 메모리를 동적으로 할당하는 경우는 특히 객체를 절대로 복사하지 않는다는 불문율이 있다. 그도 그럴 것이 크기가 큰 객체의 경우 복사에 필요한 메모리의 양이 절대적으로 부족할 수도 있고, 객체를 복사하기 위해 걸리는 시간 또한 만만치 않기 때문이다.
메모리를 동적으로 할당하고 있는 객체의 경우 – 불행하게도 프로그램을 구성하는 객체의 대부분이 이러한 객체들이다 -는 객체를 무작정 복사하는 경우 할당된 메모리를 사용하는 객체가 있음에도 불구하고 메모리를 해제해버리는 문제를 일으키기 때문에 객체를 복사할 때는 반드시 새로 메모리를 할당하고 메모리의 내용까지 복사하는 딥 카피를 해야한다. 객체의 복사가 빈번하게 일어나는 경우 딥 카피로 인해 힙의 구조에 안 좋은 효과를 주게되며, 할당된 메모리가 크지 않다고 하더라도 딥 카피에 필요한 처리 시간은 무시할 정도가 이나다.
일반적으로 객체들은 서로 참조를 하면서 연관을 맺고 있기 때문에 객체를 복사한다는 것은 이러한 연관 고리를 모두 쫓아다니면서 해당하는 모든 객체들을 딥 카피해주어야 함을 의미한다. 그렇기 때문에 일반적으로 객체를 다루는 것은 포인터에 의존하게 된다.
문제는 객체들을 STL 컨테이너에 담을 때 발생한다. STL 컨테이너는 포인터를 다루는 것을 감안하도록 설계되어 있지 않다. 즉, 모든 객체는 직접 STL 컨테이너에 담겨야 하며 객체는 필요한 경우 복사될 수 있어야 하고 심지어 비교 연산자를 통해서 객체는 같은 클래스의 다른 객체와 스스로 비교할 수 있어야 한다.
이러한 요구는 단순히 STL 컨테이너 뿐만 아니라 STL의 알고리즘을 구현한 함수들을 포함하여 STL 전체적으로 요구되는 사항이다. STL의 이러한 특성을 감안한다면 STL은 크기가 매우 작고 부담이 적은 객체들만을 다룰 것을 가정하고 작성되었다고 볼 수 있다. 사실상 C++의 프로그램에서는 포인터를 배제한 채 객체를 설계한다는 것은 현실적으로 불가능에 가깝기 때문이다.
C++0x와 STL
C++0x 규격에서는 R-value reference라는 새로운 참조가 추가되었다. R-value reference는 STL의 사용법을 비약적으로 향상시켜준다. 지금까지는 메모리를 소유하고 있는 객체가 이를 다른 객체에 넘겨주는 방법은 존재하지 않았다.
그런데 R-value reference는 객체가 다른 객체에게 리소스를 양도하는 것이 가능하도록 하며, C++0x를 구현한 컴파일러들은 R-value reference에 기반하여 STL을 재작성하였다. 즉, 이제는 STL 때문에 발생하는 딥 카피 문제를 더이상 고민하지 않아도 되는 것이다. 하지만 문제는 여전히 남아있다.
우선 여러분이 사용하는 컴파일러가 C++0x를 지원해야 한다는 것이다 – Visual C++의 경우 Visual Studio 2010부터, C++ Builder는 Rad Studio 2009 부터 R-value reference를 구현하며, 최신 버전의 g++ 역시 R-value reference를 구현한다.
둘째로 R-value reference는 리소스를 양도하기 위한 방법을 제시하는 것이지 리소스를 양도하지 않을 객체라면 여전히 딥 카피를 하던가 별도로 객체를 관리해야한다. C++0x로 구현된 STL을 사용하는 것이 좋은 선택이긴 하지만 그것에만 의존하여 프로그램을 작성하는 것은 바람직하지 않다.
스마트 포인터로 STL 컨테이너에 객체 담기
이번 글에서는 포인터로 객체를 다루지만, 이러한 객체들을 어떻게 STL 컨테이너와 제너릭 알고리즘에서 사용하는지에 대해서 다루어 볼 것이다. 가장 먼저 해결해야할 점은 객체의 리소스를 어떻게 관리하느냐인데 일반적으로 가장 좋은 방법은 레퍼런스 카운터를 이용하는 것이다.
레퍼런스 카운터는 객체에 추가 메모리 공간과 이로 인한 프로세서 캐시의 비효율화를 가져오지만 프로그램내에서 객체를 공유하기 위한 가장 좋은 방법이다. 심지어 레퍼런스 카운터는 C++의 STL에서 스마트 포인터로써 이미 구현까지 되어있다. 스마트 포인터를 사용하면 리소스의 누수로부터 안전하게 리소스를 보호할 수 있기 때문에 스마트 포인터와 레퍼런스 카운터의 조합은 매우 유용하다.
스마트 포인터 중 stl::tr1::shared_ptr 만이 레퍼런스 카운터를 구현하고 있으며, 그렇기 때문에 STL 컨테이너에서 사용이 가능하다. 일반적인 스마트 포인터는 std::auto_ptr은 리소스를 복사할 수 없기 때문에 스마트 포인터의 복사 자체가 불가능하다. 따라서, STL 컨테이너에 사용할 수 없다. 추가와 삭제가 필요한 ImagePoint라는 객체의 목록을 작성한다면 다음과 같이 타입을 정의하면 된다.
typedef std::list<std::tr1::shared_ptr> ImagePointContainer ;
typedef을 쓰는 이유는 그렇지 않을 경우에 비해서 타이핑 수고를 훨씬 줄여주기 때문이다. 뿐만 아니라 프로그램을 구현하는 과정에서 컨테이너를 다른 컨테이너로 교체해야 할 경우에 typedef으로 컨테이너를 위한 타입을 새로 생성해 두는 편이 코드의 유지 관리에도 도움이 된다. 코드의 유지 관리를 위해서 컨테이너를 사용자에게서 감추기 위해 다음과 같이 객체를 정의할 수도 있다.
typedef std::list<std::tr1::shared_ptr> ImagePointContainerImpl ;
class ImagePointContainer
{
private:
ImagePointContainerImpl container_ ;
};
직접 객체를 생성할 때의 잇점은 컨테이너에 필요한 작업을 위해서 함수를 작성할 때 컨테이너에 대한 추상화 레벨을 높이고 컨테이너의 내용과 객체의 내용을 동일하도록 유지하기가 쉽다는데 있다. 그러나 반면에 컨테이너의 인터페이스를 완전히 닫아버림으로써 STL의 많은 함수들을 사용할 수 없다는 문제가 발생한다. 그렇다고 컨테이너의 메소드 일부를 열어준다면 사실상 컨테이너를 객체로 숨기는 작업이 의미가 없어진다.
보다 유연하게 STL을 사용하고자 한다면 typedef로써 컨테이너의 타입을 새로 정의하는 정도로 그치는 편이 낫다. 분명, 이런 식으로 컨테이너를 다루는 것은 객체 지향 프로그램의 관점에서는 캡슐화를 무시하는 형국이지만, STL은 개념적으로 객체 지향과 어울리지 않으며 그러한 용도라면 굳이 STL을 사용하기 보다는 객체를 다루는데 적합한 자료 구조를 별도로 구현하는 편이 더 나을 수도 있다.
STL 함수 사용하기
STL 함수 중에서 아주 기본적인 함수가 find 함수이다. 이 함수는 컨테이너에서 조건과 맞는 객체를 찾아서 그 위치를 넘겨주는 것인데, 예를 들면 다음과 같다.
int numbers[] = { 2, 3, 5, 6, 7, 10, } ;
std::vector numberList(&numbers[0], &numbers[6]) ;
std::vector::iterator whereIsFive =
find(numberList.begin(), numberList.end(), 5) ;
STL의 함수는 이런 식으로 동작하는 것이 많다. 그러나 이러한 동작이 객체에 대해서도 적용되려면 객체는 컨테이너에 복사되어 들어갈 수 있어야하고 컨테이너의 요소는 서로 비교될 수 있어야 하기 때문에 객체는 비교 연산자를 구현해야 한다. 그런데, 우리는 앞서 객체를 포인터로써 컨테이너에 넣기 위해서 스마트 포인터를 사용해서 객체를 컨테이너에 집어 넣었다.
문제는 스마트 포인터가 비교 연산자를 포함하고 있다는 것이며, 비교 대상은 객체가 아니라 객체의 포인터라는 점이다. ImagePointContainer인 container에 (0,0), (10, 20), (20, 30), (50, 50)의 값을 가지는 ImagePoint 객체들이 담겨있다고 가정하자.
ImagePointContainer::value_type
pt1(new ImagePoint(20, 30)) ;
ImagePointContainer::iterator whereIsPT1 =
find(container.begin(), container.end(), pt1) ;
위의 코드를 실행할 경우 분명 container에는 ImagePoint(20, 30)과 동일한 객체가 있음에도 불구하고 이 두 객체의 포인터는 동일하지 않기 때문에 find 함수는 결과는 container.end()가 된다.
컨테이너를 분할하고 합치는 등의 조작을 하는 제네릭 알고리즘의 경우는 컨테이너가 담고 있는 값의 타입에 관계 없이 사용할 수 있지만, 객체의 내용을 직접 비교할 수 없기 때문에 심지어 객체를 삭제하기 위한 remove 함수마저 사용할 수 없다.
그런데, STL의 함수들은 만일 컨테이너의 요소를 비교해야하는 함수인 경우 사용자가 직접 비교 함수를 구현할 수 있도록 _if 버전들 구현하고 있다. 우리는 _if 버전과 함수 클래스를 사용하여 객체의 스마트 포인터를 담고 있는 컨테이너에서 객체를 비교할 수 있도록 구현할 것이다.
class FindImagePointEqual
{
public:
bool operator() (
const ImagePointContainer::value_type& lhs,
const ImagePointContainer::value_type& rhs) {
return *lhs == *rhs ;
}
};
ImagePointContainer::iterator whereIsPT1 =
find_if(container.begin(),
container.end(),
FindImagePointEqual()) ;
다소 번거롭지만 컨테이너의 value_type인 스마트 포인터를 받아서 직접 객체의 값을 서로 비교하는 함수 객체를 작성한 뒤 이를 _if 버전의 STL 함수에 predicate으로 전달한다.
함수 객체를 작성해야 한다는 점과 소스 코드에 많은 클래스가 뒤범벅 될 수 있다는 점이 문제인데 함수 객체의 작성은 피할 수 없는 문제이므로 어쩔 수 없지만, 함수 객체를 별도의 헤더 파일이나 구현 파일에 작성하지 않고 함수 내부에 직접 정의함으로써 소스 코드가 뒤범벅되는 일을 조금이나마 방지할 수 있다.
컨테이너의 동기화
컨테이너의 객체들에 대해서 처리한 어떤 값 – 객체의 특정 필드 값의 합 혹은 평균 등 객체 전체를 순환해야 얻을 수 있는 값 -이 있고, 이 값이 충분히 예측 가능하다면 값을 얻기 위해 일일이 객체를 뒤질 필요는 없다.
그러나 이 값은 컨테이너의 객체에 따라 영향을 받기 때문에 컨테이너의 내용이 변경된다면 반드시 수정을 해주어야 하는데 우리는 컨테이너를 별도의 객체로 숨겨주지 않았기 때문에 컨테이너의 내용이 변경되는 것을 추적할 방법이 없다.