본문 바로가기

업무용 언어/C++

[Exceptional C++ Style] 1. vector의 올바른 용법과 잘못된 용법

[Exceptional C++ Style] 1. vector의 올바른 용법과 잘못된 용법

<난이도 4>

질문

[1] vector<int> v 가 주어졌다고 할 때, 아래의 줄 A와 B의 차이는 무엇일까?

void f(vector<int>& v) {
    v[0];    // A
    v.at(0); // B
}

[2] 다음 코드를 고찰하라.

vector<int> v;

v.reserve(2);
assert(v.capacity() == 2);
v[0] = 1;
v[1] = 2;
for (vector<int>::iterator i = v.begin(); i < v.end(); i++) {
    cout << *i << endl;
}
cout << v[0];

v.reserve(100);
assert(v.capacity() == 100);
cout << v[0];
v[2] = 3;
v[3] = 4;
// ...
v[99] = 100;
for (vector<int>::iterator i = v.begin(); i < v.end(); i++) {
    cout << *i << endl;
}

이 코드를 비평하라. 스타일과 정확성 모두를 고려할 것.

해답

벡터 요소의 접근

[1] 번의 질문에서 v가 비어 있지 않다면 A와 B는 아무 차이도 없다. 하지만, v가 비어 있다면 줄 B는 std::out_of_range 예외를 던지는 것이 보장되나, 줄 A에 대해서는 어떠한 규정도 없다.

벡터 안의 요소에 접근하는 방법은 두가지인데, 하나는 vector<T>::at이고, 다른 하나는 vector<T>::operator[]이다. 전자는 범위 점검을 반드시 하도록 규정되어 있고, 후자는 범위 점검이 허용되긴 하나 필수 조건은 아니다. 표준 라이브러리 구현에 따라 다르다.

그러나 일반적으로 operator[]가 범위 점검을 하지 않도록 구현하는 것은 vector가 내장 배열을 대체할 목적으로 나왔기 때문이다. vector는 내장 배열만큼 효율적이어야 하기 때문에 내장 배열의 접근 연산자 []와 같이 범위 점검을 하지 않는다. 따라서 존재하지 않는 요소에 대한 접근은 미정의 동작이다.

C++의 철학중 “사용하지 않는 것에 대해서는 지불하지 않는다”가 깔린 것이다.

벡터 키우기

v.reserve(2);
assert(v.capacity() == 2);
v[0] = 1;
v[1] = 2;

위의 단언문은 두 가지 문제점을 가지고 있는데, 첫번째는 이 단언문이 실패할 수 있다는 것이다. reserve 호출에 의해서 vectorcapacity는 적어도 2가 된다. 그러나 이 값이 2보다 클 수도 있다. vector의 크기는 내부적으로 지수적으로 증가하게 되어 있다. 따라서 단언문을 assert(v.capacity() >= 2)와 같이 바꿔주어야 한다. 두번째는 이 단언문이 필요없다는 것인데, reserve 호출이 이미 해당 단언문을 보장해주기 때문이다. 표준 라이브러리를 의심하고 있는 경우가 아니라면 이 단언문은 필요가 없다.

벡터에 값을 집어 넣는 아래의 두줄은 명백한 실수이지만, 표준 라이브러리 구현에 따라서는 아무런 오류를 내지 않을 수 있다는 점에서 찾기 어려운 버그의 원인이 된다.

size(resize와 짝을 이룸)와 capacity(reserve와 짝을 이룸) 사이에는 다음과 같은 차이가 존재한다.

  • size, resize

    • size: 컨테이너 안에 실제로 들어 있는 요소들의 개수
    • resize: 컨테이너 끝에 요소들을 추가하거나 제거함으로써 컨테이너의 실제 요소 개수를 변경
    • size는 모든 표준 컨테이너에 존재하지만, resizelist, vector, deque에만 존재
  • capacity, reserve

    • capacity: vector가 더 많은 공간을 할당하지 않고도 담을 수 있는 잠재적인 요소들의 개수
    • reserve: 지정된 공간을 담는 데 필요한 내부 버퍼의 크기를 키움(줄이는 경우는 없음)
    • 두 함수 모두 vector에만 존재

위 예제의 경우, reserve의 호출로 capacity는 2 이상이 되었지만, v에 요소들을 추가하지는 않았기 때문에 실제 요소의 개수는 0 이다. v는 단지 요소들을 담을 공간만을 가지고 있을 뿐이다.

operator[]가 요청된 요소를 알아서 추가해주면 좋겠다고 생각하겠지만, 그런 것을 허용한다면 벡터 안에 “구멍”들이 생겨버린다. 다음 예를 보자.

vector<int> v;
v.reserve(100);
v[99] = 42; // 오류이지만 이 논의를 위해 이런 것이 허용된다고 가정하자.
// v[0..98]에는 무엇이 있을까?

operator[] 표현은 단지 내부 버퍼의 참조를 돌려줄 뿐이다. 표준 라이브러리는 프로그래머가 잘못된 코드를 쓰지 않을 정도의 지식을 가지고 있다고 가정하기 때문이다. 범위 점검을 수행하길 원한다면 operator[] 대신 v.at(0)을 사용하자.

만일 v.reserve(2) 대신 v.resize(2)를 수행했다면 v[0] = 1; v[1] = 2;는 아무런 문제가 없었을 것이다. 또는 v.push_back(1); v.push_back(2);를 사용하는 경우도 마찬가지다. push_back()은 컨테이너의 끝에 요소들을 항상 안전하게 추가하는 방법이다.

for (vector<int>::iterator i = v.begin(); i < v.end(); i++) {
    cout << *i << endl;
}

이 루프는 아무것도 출력하지 않는다. v는 여전히 비어있기 때문이다. 이 루프 자체에는 별 오류가 없다. 그러나 코드 리뷰 상으로는 몇가지 스타일상의 문제점들이 존재하는데, 다음과 같다.

  1. const 정확성을 최대한 지킬 것.
    반복자는 vector의 내용을 변경하지 않으므로 const_iterator이어야 한다.

  2. 반복자들을 비교할 때에는 < 대신 !=를 선호할 것.
    vector<T>::iterator는 임의 접근 반복자이므로, 위의 예에서는 별 문제가 없다. 그러나 <는 임의 접근 반복자에만 작동하는 반면 !=는 다른 종류의 반복자들에게도 작동하므로, <가 반드시 필요한 경우가 아니라면 항상 !=를 사용하는 습관을 들이는 것이 좋다. (또한 !=는 나중에 어떤 이유로 다른 컨테이너로 전환할 때 더 편하다. 예를 들어 std::list의 반복자는 양방향 반복자일 뿐이므로 <를 지원하지 않는다.)

  3. 후위 --,++ 대신 전위 --,++를 선호할 것.
    루프에서 i의 이전 값이 필요한 경우가 아니라면 항상 i++ 대신 ++i를 사용하는 습관을 들이자. 다만, v[i++]처럼 i번째 요소에 접근한 후에 루프 카운터를 증가시키는 경우라면 후위 표기가 더 자연스럽다.

  4. 불필요한 재계산을 피할 것.
    이 예의 경우 v.end()가 돌려준 값은 루프 도중에 변하지 않으므로, 매 반복마다 컨테이너의 끝을 재계산하는 대신 루프를 시작하기 전에 미리 계산해 두는 게 낫다.

  5. endl 보다는 '\n'을 선호할 것.
    endl은 스트림의 내부 출력 버퍼를 비우도록 강제한다. 스트림이 버퍼링되며 매번 버퍼를 밀어낼 필요가 없다면, 루프가 끝났을 때 한번만 비우게 하는 게 프로그램의 성능에 도움이 된다.

  6. 루프를 직접 작성하기보다는 표준 라이브러리의 copy와 for_each를 재사용할 것. 표준 라이브러리의 기능을 사용하는 것이 쉽고 깔끔하기 때문이다. 다만, 이는 취향의 문제일 수 있다.
    루프 본체가 단순한 경우라면 copyfor_each 쪽이 직접 짠 코드보다 가독성이 더 좋을 수 있다. 그러나 루프 본체가 좀 복잡한 경우라면 루프 본체를 함수자(functor)들로 분해해야 하기 때문에 코드가 매우 지저분해질 수 있다(c++11에서는 람다 표현식으로 해결 가능).

위의 루프는 다음처럼 바꿀 수 있다.

copy(v.begin(), v.end(), ostream_iterator<int>(cout, "\n"));

위의 코드를 사용했을 때에는 !=, 전위 ++, end(), endl에 대한 실수는 애초에 사라진다. 내부적으로 copy 자체가 그런 것들을 처리하기 때문이다. 만약 스트림을 매번 비워야 할 경우는 루프 본체를 직접 짜는 수 밖에 없다.

(c++11에서는 간단하게 const_iterator 문제도 해결할 수 있는 방법이 있는데, cbegin()cend()를 사용하면 된다.)

다음으로 출력 코드를 보자.

cout << v[0];

위의 결과로 1이 출력될 수도 있다. 이는 프로그램이 잘못된 방식으로 메모리에 뭔가를 썼지만 그게 즉시 문제를 일으키지는 않았기 때문인데, 사실 그게 더 골치 아픈 문제이다.

v.reserve(100);
assert(v.capacity() == 100);
cout << v[0];

앞에서와 마찬가지로 이 단언문 역시 >=를 사용해야 하며, 게다가 불필요하다.
그리고 v[0]을 출력하면 0이 나온다.

reserve(100) 때문에 v의 내부 버퍼가 실제로 재할당 되었다고 가정하자(즉 최초의 reserve(2)가 벡터의 용량을 100 이상으로 증가시키지 않았다고 하자). 그런 경우는 이미 들어있던 요소들만을 새 버퍼에 복사할 것이다. 그런데 v에는 아무 것도 들어있지 않았다. 그래서 새 버퍼는 그냥 정의되지 않은 값(흔히 0)들만 가지게 된다.

이 이후의 코드들도 앞에서 설명한 이유와 마찬가지로 잘못되었으므로 바로 잡아야 한다.