<난이도 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
호출에 의해서 vector
의 capacity
는 적어도 2가 된다. 그러나 이 값이 2보다 클 수도 있다. vector
의 크기는 내부적으로 지수적으로 증가하게 되어 있다. 따라서 단언문을 assert(v.capacity() >= 2)
와 같이 바꿔주어야 한다. 두번째는 이 단언문이 필요없다는 것인데, reserve
호출이 이미 해당 단언문을 보장해주기 때문이다. 표준 라이브러리를 의심하고 있는 경우가 아니라면 이 단언문은 필요가 없다.
벡터에 값을 집어 넣는 아래의 두줄은 명백한 실수이지만, 표준 라이브러리 구현에 따라서는 아무런 오류를 내지 않을 수 있다는 점에서 찾기 어려운 버그의 원인이 된다.
size
(resize
와 짝을 이룸)와 capacity
(reserve
와 짝을 이룸) 사이에는 다음과 같은 차이가 존재한다.
size
,resize
size
: 컨테이너 안에 실제로 들어 있는 요소들의 개수resize
: 컨테이너 끝에 요소들을 추가하거나 제거함으로써 컨테이너의 실제 요소 개수를 변경size
는 모든 표준 컨테이너에 존재하지만,resize
는list
,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
는 여전히 비어있기 때문이다. 이 루프 자체에는 별 오류가 없다. 그러나 코드 리뷰 상으로는 몇가지 스타일상의 문제점들이 존재하는데, 다음과 같다.
const
정확성을 최대한 지킬 것.
반복자는vector
의 내용을 변경하지 않으므로const_iterator
이어야 한다.반복자들을 비교할 때에는
<
대신!=
를 선호할 것.
vector<T>::iterator
는 임의 접근 반복자이므로, 위의 예에서는 별 문제가 없다. 그러나<
는 임의 접근 반복자에만 작동하는 반면!=
는 다른 종류의 반복자들에게도 작동하므로,<
가 반드시 필요한 경우가 아니라면 항상!=
를 사용하는 습관을 들이는 것이 좋다. (또한!=
는 나중에 어떤 이유로 다른 컨테이너로 전환할 때 더 편하다. 예를 들어std::list
의 반복자는 양방향 반복자일 뿐이므로<
를 지원하지 않는다.)후위
--
,++
대신 전위--
,++
를 선호할 것.
루프에서 i의 이전 값이 필요한 경우가 아니라면 항상i++
대신++i
를 사용하는 습관을 들이자. 다만,v[i++]
처럼i
번째 요소에 접근한 후에 루프 카운터를 증가시키는 경우라면 후위 표기가 더 자연스럽다.불필요한 재계산을 피할 것.
이 예의 경우v.end()
가 돌려준 값은 루프 도중에 변하지 않으므로, 매 반복마다 컨테이너의 끝을 재계산하는 대신 루프를 시작하기 전에 미리 계산해 두는 게 낫다.endl
보다는'\n'
을 선호할 것.
endl
은 스트림의 내부 출력 버퍼를 비우도록 강제한다. 스트림이 버퍼링되며 매번 버퍼를 밀어낼 필요가 없다면, 루프가 끝났을 때 한번만 비우게 하는 게 프로그램의 성능에 도움이 된다.루프를 직접 작성하기보다는 표준 라이브러리의 copy와 for_each를 재사용할 것. 표준 라이브러리의 기능을 사용하는 것이 쉽고 깔끔하기 때문이다. 다만, 이는 취향의 문제일 수 있다.
루프 본체가 단순한 경우라면copy
와for_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
)들만 가지게 된다.
이 이후의 코드들도 앞에서 설명한 이유와 마찬가지로 잘못되었으므로 바로 잡아야 한다.
'업무용 언어 > C++' 카테고리의 다른 글
C++11 살펴보기 (0) | 2016.03.10 |
---|---|
[Effective C++] 항목 6. 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자 (0) | 2016.01.28 |
[Effective C++] 항목 5. C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자 (0) | 2016.01.28 |
[C++11] 생성자 위임 (0) | 2016.01.25 |
[Effective C++] 항목 4. 객체를 사용하기 전에 반드시 그 객체를 초기화하자 (0) | 2016.01.25 |