잊지말자 string 은 여러 가지 방식으로 구현되어 있다는 사실을…

string이 기본적으로 가지는 정보


  • 문자열의 크기 (size)
  • 문자를 담아두는 메모리의 용량(capacity)
    용량은 실제 문자열의 개수와 다를수 있다.

  • 문자열의 값(value)
선택적인 정보

  • 할당자의 사본
참조 카운팅을 사용하는 string의 추가적인 정보

  • 문자열 값에 대한 참조 카운트(reference count)
이런 정보들은 각각의 STL에 따라 여러가지 방법은로 저장, 관리 된다.
예를 들어 string 객체가 위의 정보를 클래스의 인스턴스로 가지고 있는 방법이 있을수도 있고 string 객체는 실제로는 이런 정보를 가지고 있는 객체를 나타내는 포인터일수도 있다.( 이런 경우 string 객체를 생성할때 동적할당이 2번일어 날수 있다.)
또한 어떤 STL의 string 의 경우는 15자 이하는 내부버퍼에 담아두고 그 이상되는 문자열의 경우 동적할당을 하여 값을 저장하며 참조카운터등을 전혀 사용하지 않는다.





정리

  • string 문자열의 값은 참조 카운팅이 될 수도, 되지 않을 수도 있습니다. 기본적으로 많은 라이브러리에서 참조 카운팅을 사용하고 있습니다만, 이 기능의 동작을 막는 방법(대개 전처리자 매크로를 통해)도 제공하고 있습니다. 참조 카운팅 기능을 막거나 사용해야 하는 경우에 대해서는 항목 13에서 이야기해 두었습니다. 예를 들어, 동일한 문자열이 아주 자주 복사되고 어떤 애플리케이션 자체에서 문자열 복사를 많이 하지 않을 때에만 참조 카운팅이 효과적입니다.
  • string 객체 자체의 크기는 포인터 크기의 한 배에서 일곱 배까지 다양합니다.
  • 문자열을 새로 생성할 때 필요한 메모리 할당의 회수는 0번, 1번 또는 2번이 될 수도 있습니다.
  • 문자 버퍼를 위해 할당하는 메모리의 최소량에 대한 정책은 구현된 라이브러리마다 천만별 입니다.

reserve는 필요 없이 메모리가 재할당되는 것을 막아 주다.

vector와 string에 있어서의 메모리 증가는 재할당이란 과정을 거쳐서 이루어 진다.(현재 사이즈의 증가가 아닌 컨테이너에서 할당해놓은 메모리가 부족해 질때)


  1. 컨테이너의 현재 용량의 몇배가 되는 메모리 블록을 새로 할당 (일반적으로 2배)
  2. 컨테이너가 가지고 있었던 메모리에 저장된 모든 요소 데이타(객체)를 새 메모리에 복사
  3. 원래 메모리에 저장된 모든 객체를 소멸
  4. 원래의 메모리를 해제
라는 과정을 거친다.
이런 오버헤드를 줄이기 위해서 필요한 만큼의 메모리를 미리할당하고 데이타를 삽입한뒤 관리하는 방식을 사용할수 있다. 이때 사용하는것이 reserve 이다.
예를 들어
vector<int> v;
for(int i=0; i<1000; i++) v.push_back(i);
라는 코드는 루프를 도는 동안 대략 10번 정도의 메모리 재할당이 일어나게 된다.(메모리크기를 2배씩 잡을때) 하지만 reserve를 사용하면 이런 불필요한 메모리 재할당을 막을수 있다.
vector<int> v;
v.reserve(1000);
for( int i=0; i<1000; i++ ) v.push_back(i);
위의 코드는 루프를 도는 동안 메모리 할당이 한번도 일어나지 않게 된다.
그리고 size와 capacity의 관계를 잘 생각해보면 요소 삽입시 vector나 string에서 재할당이 일어나는 시기를 예측할수 있다.
string s;

if( s.size() < s.capacity() ) s.push_back(‘X’);

정리하면 reserve를 이용해서 불필요한 메모리 재할당을 피하는 방법은 두가지 정도 이다.

  • 컨테이너에 저장될 요소의 개수를 미리 파악해서 정확한 reserve 호출
  • 컨테이너에 저장될 최대 요소의 개수를 할당하고 요소 삽입후 남는 메모리를 삭제 (EffectiveSTL17 을 참고)





참고

  • size() : 현재 컨테이너에 들어있는 요소의 개수
  • capacity() : 현재 컨테이너에 재할당없이 추가할수 있는 요소의 개수
  • resize(size_t n) : 컨테이너의 요소의 개수를 n으로 변경. 만약 현재 컨테이너의 개수가 n보다 크다면 n이후의 요소들은 삭제, 크다면 n크기로 재할당하고 뒤에 남는 공간은 요소 객체의 기본생성자로 생성해 채워 놓는다.
  • reserve(size_t n) : 컨테이너의 용량을 최소 n으로 맞춘다. 현재의 capacity()가 더 작다면 n의 크기로 재할당을 하며, 크다면 아무것도 하지 않는다. (이미 할당된 메모리의 공간을 줄이는 일이 일어날수도 있지만 삽입된 데이타를 줄이는 일은 없다.)

STL 컨테이너가 쓰레드 안정성에 대한 기대는 현실에 맞추어 가자

SGI에서 제정한 STL 다중 쓰레딩 지원 항목







  • 여러 쓰레드에서 읽는 것은 안전하다.(Multiple readers are safe)
    하나 이상의 쓰레드가 하나의 컨테이너의 내용을 동시에 읽는 경우가 있는데, 제대로 동작합니다. 당연한 이야기지만 읽기 도중에 쓰기 종작이 수행되면 안됩니다.
  • 여거 쓰레드에서 다른 컨테이너에 쓰는 것은 안전하다.(Multiple writers to different containers are safe)
    하나 이상의 쓰레드가 다른 컨테이너에 동시에 쓸수 있다.


STL에서는 위에 써있는 당연한것 이외에는 다중 쓰레딩 환경에 대해 전혀 지원받을수 없으니 자체적으로 해결하는 것이 좋다.
클라이언트 코드레벨에서 뮤텍스나 크리티컬섹션을 처리해 주는 수밖에 없다.

Lock 클래스의 구현을 생각해 보는것도 좋다 Lock 클래스 같은 경우는 생성자에서 객체(일반적으로 컨테이너)를 받아 다른 쓰레드에서 사용 못하도록 처리하고 소멸자에서 잠근걸 푸는 형식이 일반적이다.
vector<int> v;

{
Lock(v); // 잠근다.
……
} // 자동으로 소멸.
위의 코드 같은 경우 예외 상황이 발생해도 소멸자의 호출은 보장하므로 v가 영원히 다른 쓰레드에서 사용못하게 되는 일은 없다.

데이타를 삭제할 때에도 조심스럽게 선택할것이 많다.

컨테이너네서 특정한 값을 가진 객체를 모두 없애려면







  • 컨테이너가 vector, string 혹은 deque 이면, erase-remove 합성문을 씁니다.
  • 컨테이너가 list 이면, list::remove 를 씁니다.
  • 컨테이너가 표준 연관 컨테이너이면, erase 멤버함수를 씁니다.


컨테이너에서 특정한 술어 구문을 만족하는 객체를 모두 없애려면







  • 컨테이너가 vector, string 혹은 deque 이면, erase-remove_if 합성문을 씁니다.
  • 컨테이너가 list이면, list::remove_if를 씁니다.
  • 컨테이너가 표준 연관 컨테이너이면, remove_copy_if와 swap을 쓰든지, 컨테이너 내부를 도는 루프에서 erase를 호출하면서 erase에 넘기는 반복자를 후위증가연산자로 증가시킵니다.


루프 안에서 무엇인가를 하려면(객체 삭제도 포함해서):







  • 컨테이너가 표준 시퀀스 컨테이너이면, 컨테이너 요소를 하나씩 사용하는 루프를 작성합니다. 이때 erase를 호출할 때마다 그 함수의 반환값으로 반복자를 업데이타하는 일을 꼭 해야 합니다.
  • 컨테이너가 표준 연관 컨테이너이면, 역시 컨테이너 요소를 하나씩 사용하는 루프를 작성합니다. 이때 erase를 호출하면서 erase에 넘기는 반복자를 후위 증가 연산자로 증가 시킵니다.

auto_ptr의 컨테이너는 절대로 만들지 말자

auto_ptr 의 복사의 의미 auto_ptr 하나를 복사(copy)하면, auto_ptr이 가르키는 객체의 소유권은 복사하는 auto_ptr로 옮겨지고 복사되는 auto_ptr은 NULL로 세팅된다. auto_ptr을 복사하는 것은 그 포인터의 값을 바꾸는것 라고 말할수 있다.

auto_ptr<Widget> pw1(new Widget);        // pw1은 생선된 Widget을 가르킨다.
auto_ptr<Widget> pw2(pw1); // pw2는 pw1의 Widget을 가르키고
// pw1는 NULL로 세팅된다.
pw1 = pw2; // pw1는 다시 Widget을 가르키고 pw2는 NULL

여기서 생기는 문제는 STL의 컨테이너에서 값 넣는것 값을 꺼내오는것 대부분의 동작이 복사로 이루어진다는것이다. 간단한 예로 사용자가 vector에서 값을 꺼내올때

auto_ptr<Object> ap = Vec[0];

이라는 코드를 쓸때 ap에는 원하는 값이 들어가지만 Vec[0]에는 NULL로 세팅되어 버리는것이다. STL 의 기본적인 동작뿐만 아니라 알고리즘, 함수 등 모든 부분에서 복사를 사용하므로 auto_ptr을 컨테이너에 넣을 경우 원하지 않는 동작을 하게 될것이다.

auto_ptr을 컨테이너에 넣는 행위는 표준화위원회에서도 금지 이니 그냥 신경 안쓰는게 좋다.

new로 생성한 포인터의 컨테이너를 사용할 때에는 컨테이너가 소멸되기 전에 delete하는 일을 잊지 말자

void doSomething()
{
vector<Widget*> vwp;
for( int i = 0; i < SOME_NUMBER; ++i )
{
vwp.push_back(new Widget);
}

}

위와 같은 코드를 보면 vwp 라는 컨테이너가 doSomething을 빠져나가면서 소멸될때 메모리가 샐것이라는것을 알수 있다. 이 문제는 스마트포인터를 사용하지 않는 이상 사용자 처리해 주어야 하는데 일반적으로 이렇게 짤수 있다.


struct DeleteObject{
template<typename T>
void operator()(const T* ptr) const
{
delete ptr;
}
};

void doSomething()
{
vector<Widget*> vwp;
for( int i = 0; i < SOME_NUMBER; ++i )
{
vwp.push_back(new Widget);
}

for_each(vwp.begin(), vwp.end() DeleteObject() );
}


하지만 위의 코드 역시 vwp 에 데이타를 삽입한 이후 예외상황이 발생하면 메모리는 새게 된다. 이 문제를 해결하는 방법은 스마트포인터를 사용하는 수밖엔 없다.
스마트포인터는 Boost 라이브러리의 shared_ptr 을 사용하면 좋다.

주의사항 : auto_ptr을 컨테이너에 넣으면 안된다

C++ 컴파일러의 어이없는 분석결과를 조심하자

ifstream dataFile(“ints.dat”);
list<int> data(istream_iterator<int>(dataFile),
istream_iterator<int>() );

위의 코드는 범위생성자를 사용해서 “ints.dat” 파일의 정수값을 읽어 list에 넣는다는 의미이다. 컴파일이 되기는 하지만 정상적으로 작동하지는 않을 것이다.
자세한 이유는 생략한다.
위의 코드가 정상적으로 작동하지 않는 이유는
class Widget{…};    // 기본생성자가 있다고 가정한다.
Widget w(); // 아무것도 받아들이지 않는고
// Widget을 반환하는 함수 w를 선언한다.

이것과 유사한 이유이다. 어쨋거나 위의 코드같은 문제가 일어나지 않기를 바란다면 익명객체선언을 istream_iterator에 쓰지말고 각 반복자를 변수로 할당하는 것이다.
ifstream dataFile(“ints.dat”);
istream_iterator<int> dataBegin(dataFile);
istream_iterator<int> dataEnd;
list<int> data(dataBegin, dataEnd);

단일요소를 단위로 동작하는 멤버함수보다 요소의 범위를 단위로 동작하는 멤버함수가 더 낫다.

두개의 백터 v1과 v2가 있다고 할때, v1의 내용을 v2의 뒤쪽 반과 같이 하는 가장 간단한 방법은





v1.assign(v2.begin()+v2.size()/2, v.end());

이다.
물론 루프를 사용할수도 있으며 copy 알고리즘과 back_insert 삽입연산자를 사용할수도 있다. 하지만 가능하다면 이경우는 vector에서 제공하는 멤버함수를 사용하는것이 좋다.

  • 범위멤버함수를 사용한 코드가 대개 짧다.
  • 범위멤버함수는 훨씬 명확하고 간결한 의미를 전달한다.
이다.
다른 예로 int 배열을 vector의 앞쪽에 복사할 경우
int data[numValues];
vector<int> v;

v.insert(v.begin(), data, data + numValues);
의 코드를 사용하게 되는데 이것을 copy 알고리즘을 이용해서 구현하면
copy(data, data + numValues, inserter(v, v.begin()));
같은 코드로 구성이 된다. 하지만 copy와 삽입연산자를 사용할 경우

  1. numValues 번에 해당하는 insert 함수의 호출하는 비용
  2. 앞쪽에다가 삽입함으로써 insert 호출시마다 vector를 뒤로 밀어버리는 비용
  3. 삽입시 컨테이너가 모자를 경우 생기는 메모리 할당 비용
같은 비용이 추가로 부담되게 된다. 왜냐하며 copy는 내부적으로 루프를 사용하는 것과 비슷한 방식으로 구현되어 vector의 insert를 여러번 호출하는것이나 마찬가지 이기 때문이다. 하지만 vector의 범위멤버인 insert를 호출함으로써 한번의 호출시 작업해야하는 범위를 파악하여 메모리 할당문제를 해결하고 순차적으로 각값을 넣음으로써 위의 세가지 비용문제를 해결할수 있다.

이런 문제는 vector 보다는 적지만 list 에서도 마찬가지 이다. list에 insert 를 호출시 삽입되는 위치의 앞과 뒤에 있는 노드의 서로를 연결시키는 포인터값을 수정해야 하며 이것 역시 추가적인 비용이 들어간다. 범위 insert를 호출한다면 위의 비용은 한번으로 줄어들 것이다.





정리

  • 범위 생성
    istream_iterator, istreambuf_interator 를 사용할 경우에는 주의
  • 범위 삽입 : 대부분의 컨테이너는 insert류의 함수를 제공한다. (연관컨테이너는 위치지정을 내부에서 비교함수를 따로 사용하기 때문에 위치지정은 없다.) push_back, push_front 나 back_insert, front_insert를 사용하는 루프나 copy 알고리즘이 있다면 insert 범위 멤버함수로 변경가능하다.
  • 범위 삭제 : erase 함수를 이영해서 삭제
  • 범위 대입 : 표준연속컨테이너에서 assign을 제공

size()의 결과를 0과 비교할 생각이라면 차라리 empty를 호출하자

STL에서 제공하는 컨테이너들은 각각의 라이브러리 마다 여러가지 방법으로 구현되어 있으며 당연히 컨테이너에서 실제로 추가되어 있는 객체의 개수를 얻는 방법도 여러가지 방법이다.
이것은 컨테이너의 size() 함수가 여러가지 방법으로 구현되어 있을수 있다는 의미이며 정확한 규칙에 의거한 속도를 보장하지는 않는 의미다.

예를 들어 list 의 객체의 개수를 간단히 얻는 방법은 처음부터 끝까지 노드를 따라가며 세어보는 방법이지만 list::size_type 을 맴버변수로 가지고 계속 값을 업데이트 해줌으로써 상수시간에 객체의 개수를 얻을 수도 있다.
하지만 list 의 splice 함수의 동작은 변경되는 개수가 몇개인지 세어보기 전에는 알수 없기때문에 값을 계속 업데이트 해주는 방법은 splice 함수에 불필요한 연산을 요구하게 된다. 어떤 방법에 의해서 size()와 splice 를 구현할지는 라이브러리제작자의 판단에 따르게 될것이다.

이런 이유로 라이브러리 간의 size()함수의 구현 방법이 다를수도 있으며 size() 함수가 선형적인 시간을 요구할수도 있으므로 해당 컨테이너가 단순히 비어있는지 아닌지 를 판단하기 위해서라면 간단히 상수시간에 결과를 리턴해주는 empty() 사용하는것이 좋다.