STL의 vector는 가장 흔하게 사용되는 컨테이너 중 하나이다.
C/C++의 배열을 표방하고 있으며, operator []를 제공함으로써 사용법도 크게 낯설지 않다.
하지만, 사용 빈도에 비례하여 잘못된 용법으로 사용하는 경우도 그만큼 많다.
신참 질문
"vector<int> v" 가 주어졌을 때, 아래의 줄 A와 B의 차이는 무엇일까?
void f(const vector<int>& v)
{
v[0]; // A
v.at(0); // B
}
위 예제에서 A와 B 라인은 벡터 요소에 접근하는 두 가지 방법을 보여주고 있다.
vector<int> v의 0번째 인덱스에 요소가 존재한다면 A와 B는 아무런 차이가 없다.
vector<int> v의 0번째 인덱스에 요소가 존재한다면 A와 B는 아무런 차이가 없다.
하지만, v가 비어 있는 경우 B라인은 std::out_of_range 예외를 던져주지만,
A의 경우 미정의 결과(컴파일러마다 그 처리가 다름)가 발생한다.
vector::at()의 경우 요청된 인덱스에 요소가 존재하는지 확인하기 위한 범위 체크를 반드시 하도록 되어 있다.
하지만, operator [] 의 경우 표준 명세서에 범위 체크에 대한 언급조차 없다.
VS2010 sp1으로 A 라인을 테스트 해보면 결과는 다음과 같다.
A의 경우 미정의 결과(컴파일러마다 그 처리가 다름)가 발생한다.
vector::at()의 경우 요청된 인덱스에 요소가 존재하는지 확인하기 위한 범위 체크를 반드시 하도록 되어 있다.
하지만, operator [] 의 경우 표준 명세서에 범위 체크에 대한 언급조차 없다.
VS2010 sp1으로 A 라인을 테스트 해보면 결과는 다음과 같다.
debug : Debug Assertion Failed! (Expression : vector subscript out of range)
release : v가 빈 벡터일 경우 f() 함수는 컴파일 과정에서 제거되어 호출도 되지 않는다.
차라리 debug 모드처럼 에러를 뿜어주면 고마운데...
release에서처럼 씹어버리면 더 큰 문제를 초래할 수 있고, 그 원인을 찾기가 더욱 더 힘들어질 수 있다.
반면에 at()의 경우 debug/release 관계없이 std::out_of_range 예외가 착실히 던져진다.
그렇다면, operator []가 반드시 범위 체크를 수행하도록 강제하지 않는 이유는 무엇인가?
1. 성능상의 이유. 작지만 굳이 지불하지 않아도 되는 비용은 치르지 않겠다는 이유이다.
2. C++의 배열처럼 쓰이도록 고안된 녀석.
어느 녀석이 절대 좋다 나쁘다가 아니라 컨텍스트를 잘 따져 어울릴만한 녀석을 골라쓸 줄 알아야 한다는 내용이다.
고수 질문
다음 코드에 대해 비평하라.
다음 코드에 대해 비평하라.
vector<int> v;
v.reserve(2);
assert(v.capacity() == 2); // 1
v[0] = 1; // 2
v[1] = 2; // 2
for (vector<int>::iterator ib = v.begin(); ib < v.end(); ib++) // 3
{
cout << *i << endl; // 4
}
cout << v[0]; // 2
v.reserve(100);
assert(v.capacity() == 100); // 1
cout << v[0]; // 2
v[2] = 3; // 2
v[3] = 4;
//...
v[99] = 100;
for (vector<int>::iterator ib = v.begin(); ib < v.end(); ib++) // 3
{
cout << *i << endl; // 4
}
딱 봐도 위 코드는 상당히 많은 오류를 포함하고 있으며, 런타임 에러가 발생할 수 밖에 없도록 작성되어 있다.
문제의 유형별로 주석을 달아 놓았다. 하나씩 왜 그렇게 되는지 살펴보도록 하자.
1번 타입. 잘못된 assert() 호출, 그리고 불필요한 호출
vector::reserve(size_type _Count) 함수에 대해 표준에서는 다음과 같이 명세하고 있다.
"벡터의 메모리 공간을 최소 _Count만큼 담을 수 있도록 저장 공간을 확보하라"
즉, reserve(2)를 호출한다고 해서 capacity는 딱 2가 되는 것이 아니라, 2보다 같거나 큰 수가 될 수 있는 것이다.
이 역시 벡터를 구현한 컴파일러마다 약간씩 다르게 구현되어 있긴 하다.
참고로, vs2010 sp1 에서는 정확하게 인자로 넘긴 크기 만큼만 capacity가 늘어남을 확인할 수 있었다.
하지만, 제네릭한 코드를 작성하기 위해서는 다음과 같이 assert 조건이 달라져야 한다.
문제의 유형별로 주석을 달아 놓았다. 하나씩 왜 그렇게 되는지 살펴보도록 하자.
1번 타입. 잘못된 assert() 호출, 그리고 불필요한 호출
vector::reserve(size_type _Count) 함수에 대해 표준에서는 다음과 같이 명세하고 있다.
"벡터의 메모리 공간을 최소 _Count만큼 담을 수 있도록 저장 공간을 확보하라"
즉, reserve(2)를 호출한다고 해서 capacity는 딱 2가 되는 것이 아니라, 2보다 같거나 큰 수가 될 수 있는 것이다.
이 역시 벡터를 구현한 컴파일러마다 약간씩 다르게 구현되어 있긴 하다.
참고로, vs2010 sp1 에서는 정확하게 인자로 넘긴 크기 만큼만 capacity가 늘어남을 확인할 수 있었다.
하지만, 제네릭한 코드를 작성하기 위해서는 다음과 같이 assert 조건이 달라져야 한다.
assert(v.capacity() >= 2);
그리고, 과연 이 assert() 가 필요한가?
개인적으로 작업할 때 assert() 류의 함수를 이용해 에러를 검출하는 방식은 크게 좋아하지 않는 편이다.
대부분 release 환경에서는 아무런 디텍팅을 하지 못하는 데,
assert()로 마치 예외 처리를 다 한 듯이, 이하 코드를 작성하는 것에 대해 신용을 하지 않기 때문이다.
그리고, 책에서는 위 assert()에 대해 불필요하다고 한 이유로, 표준에서 reserve() 행위를 보장해 주기 때문이라고 했다.
개인적으로 서비스에 올려야 하는 코드라면, 어떤 식으로든 예외 체크가 필요하다는 생각이라 책의 내용에 동의하진 않는다.
다만, 그 방법이 assert() 라는 것이 불편하다는 것이다.
2번 타입. reserve()에 대한 오해
vector의 크기에 관련된 함수는 두 가지로 나눌 수 있고, 이 두 함수의 차이를 제대로 이해하고 있어야 한다.
1) reserve() - capacity()
vector가 추가적인 공간의 할당을 하지 않고도, 요소를 추가할 수 있도록 vector의 메모리 공간만 확장시키는 함수.
즉, 메모리 공간만 늘어났지 실제 요소가 추가되어 존재하지는 않는 상태이다.
큰 수의 capacity를 가진 상태에서 더 작은 수로 reserve()를 해도 capacity는 줄어들지 않는다.
2) resize() - size()
resize()는 vector 내 실제 요소수를 변경한다.
실제 요소수를 확장할 때에는 기존 vector의 끝에 요소들을 추가시키며 해당 요소의 초기화까지 책임져준다.
즉, 실제로 요소가 들어가 있게 되는 것이다.
반대로 resize()를 이용해 요소 수를 줄이게 되면 끝에서부터 하나씩 요소를 제거해 나가며, 실 요소수가 줄어들게 된다.
다시 2번 타입으로 돌아가서 문제가 되는 코드들을 살펴보면...
reserve() 함수를 이용해 할당 공간만 늘렸을 뿐, 실제 요소는 존재하지도 않는데
그 요소에 대해 operator []를 이용해 엑세스했기 때문에 런타임에 미정의 결과가 발생하는 것이다.
이에 대한 해결책은 아래와 같이 두 가지 방법이 있다.
해결책 #1 : reserve - push_back
해결책 #2 : resize - operator []
3번 타입. vector iterating의 정확성과 효율 문제
3번 부분은 크나큰 오류가 있다기보다는 개선되어야 할 점이 많은 코드이다.
하나씩 그 이유와 개선책을 알아보도록 하자. 개선이 필요한 정도에 따라 별을 메겨 보았다.
1) vector<int>::iterator ib (★☆)
위 루프에서는 vector 내 요소들에 대한 수정이 전혀 발생하지 않는다.
따라서, vector<int>::const_iterator 로 사용하는 것이 바람직하다.
2) ib < v.end() (★★☆)
iterator 비교시 가급적 "<" 나 ">" 로 비교하는 것은 바람직하지 않다.
물론 vector의 경우 별 문제가 없지만, "<" 또는 ">" 비교는 랜덤 엑세스 이터레이터에서만 동작한다.
반면 "!="는 다른 종류의 이터레이터에도 동작하므로,
코드의 일관성이나 추후 엉뚱한 실수를 하지 않기 위해 != 로 습관을 들이는 것이 좋다.
당장 list만 하더라도 양방향 이터레이터 이하만 지원하므로 "<" 나 ">"로 이터레이터 비교가 불가능하다.
그리고, 과연 이 assert() 가 필요한가?
개인적으로 작업할 때 assert() 류의 함수를 이용해 에러를 검출하는 방식은 크게 좋아하지 않는 편이다.
대부분 release 환경에서는 아무런 디텍팅을 하지 못하는 데,
assert()로 마치 예외 처리를 다 한 듯이, 이하 코드를 작성하는 것에 대해 신용을 하지 않기 때문이다.
그리고, 책에서는 위 assert()에 대해 불필요하다고 한 이유로, 표준에서 reserve() 행위를 보장해 주기 때문이라고 했다.
개인적으로 서비스에 올려야 하는 코드라면, 어떤 식으로든 예외 체크가 필요하다는 생각이라 책의 내용에 동의하진 않는다.
다만, 그 방법이 assert() 라는 것이 불편하다는 것이다.
2번 타입. reserve()에 대한 오해
vector의 크기에 관련된 함수는 두 가지로 나눌 수 있고, 이 두 함수의 차이를 제대로 이해하고 있어야 한다.
1) reserve() - capacity()
vector가 추가적인 공간의 할당을 하지 않고도, 요소를 추가할 수 있도록 vector의 메모리 공간만 확장시키는 함수.
즉, 메모리 공간만 늘어났지 실제 요소가 추가되어 존재하지는 않는 상태이다.
큰 수의 capacity를 가진 상태에서 더 작은 수로 reserve()를 해도 capacity는 줄어들지 않는다.
2) resize() - size()
resize()는 vector 내 실제 요소수를 변경한다.
실제 요소수를 확장할 때에는 기존 vector의 끝에 요소들을 추가시키며 해당 요소의 초기화까지 책임져준다.
즉, 실제로 요소가 들어가 있게 되는 것이다.
반대로 resize()를 이용해 요소 수를 줄이게 되면 끝에서부터 하나씩 요소를 제거해 나가며, 실 요소수가 줄어들게 된다.
다시 2번 타입으로 돌아가서 문제가 되는 코드들을 살펴보면...
reserve() 함수를 이용해 할당 공간만 늘렸을 뿐, 실제 요소는 존재하지도 않는데
그 요소에 대해 operator []를 이용해 엑세스했기 때문에 런타임에 미정의 결과가 발생하는 것이다.
이에 대한 해결책은 아래와 같이 두 가지 방법이 있다.
해결책 #1 : reserve - push_back
// 해결책 #1
vector<int> v;
v.reserve(2);
// reserve를 통해 메모리 공간을 확보했기 때문에...
// 아래 push_back 함수들은 메모리 확보 없이 바로 수행될 수 있다.
v.push_back(1);
v.push_back(2);
해결책 #2 : resize - operator []
// 해결책 #2
vector<int> v;
v.resize(2);
// resize를 통해 이미 요소들이 존재하므로 operator []로 엑세스 가능
v[0] = 1;
v[1] = 2;
3번 타입. vector iterating의 정확성과 효율 문제
3번 부분은 크나큰 오류가 있다기보다는 개선되어야 할 점이 많은 코드이다.
하나씩 그 이유와 개선책을 알아보도록 하자. 개선이 필요한 정도에 따라 별을 메겨 보았다.
1) vector<int>::iterator ib (★☆)
위 루프에서는 vector 내 요소들에 대한 수정이 전혀 발생하지 않는다.
따라서, vector<int>::const_iterator 로 사용하는 것이 바람직하다.
2) ib < v.end() (★★☆)
iterator 비교시 가급적 "<" 나 ">" 로 비교하는 것은 바람직하지 않다.
물론 vector의 경우 별 문제가 없지만, "<" 또는 ">" 비교는 랜덤 엑세스 이터레이터에서만 동작한다.
반면 "!="는 다른 종류의 이터레이터에도 동작하므로,
코드의 일관성이나 추후 엉뚱한 실수를 하지 않기 위해 != 로 습관을 들이는 것이 좋다.
당장 list만 하더라도 양방향 이터레이터 이하만 지원하므로 "<" 나 ">"로 이터레이터 비교가 불가능하다.
typedef vector<int>::const_iterator CIter;
CIter cib = v.begin();
for ( ; cib != v.end(); ++cib)
{
// do something...
}
근래 컴파일러가 좋아지면서, v.end()를 컴파일 단계에서 최적화가 이루어져 밖으로 빠져 계산되는 경우도 있긴 하다.
3) ib++ (★★★) : iterator의 후위 증가
이는 성능상의 이유로 전위 증감을 사용하는 것을 추천하는 데 후위 연산을 하게 되면 임시 객체가 생성된다.
반드시 이전 값을 활용해야 하는 경우가 아니라면, 바로 위 그림의 ++cib 처럼 전위 증감을 사용토록 하자.
4) std::endl 보다는 '\n'을 애용하자. (★☆)
std::endl 은 항상 스트림의 내부 출력 버퍼를 비우도록 강제한다.
즉, 위 루프에서 endl 이 호출될 때마다 버퍼가 비워지고 새로 채워지게 되는 비용이 발생하는 것이다.
뉴 라인 때문에만 endl 을 사용한다면, 다음과 같이 작성하는 것이 버퍼 초기화 비용을 아낄 수 있다.
for ( ; cib != cie; ++cib)
{
std::cout << *cib << "\n"; // std::endl 보다는 "\n"을 애용하자
}
5) 직접 작성하는 루프보다는 STL의 알고리즘을 이용하자. (★)
Effective STL인가 C++인가에서도 나오는 내용이지만, 솔직히 이 부분은 취향의 문제라고 생각한다.
직접 짠 루프보다 표준 라이브러리의 알고리즘을 이용해 작성된 루프가 훨씬 더 직관적이고,
위에서 주구장창 지적되었던 반복자 사용 문제들이 사라지기도 한다.
하지만, 알고리즘의 조합(보통 for_each와 다른 함수 한개 정도)의 생김새가
for 루프 본연의 생김새가 주는 직관적인 느낌보다 덜 직관적인 경우도 많기 때문이다.
또한, 프로그래머들이 STL 알고리즘에 대해 광범위하게 알고 있는 경우가 그렇게까지 많지도 않다.
직접 작성한 루프가 아주 가독성이 떨어지거나 그 루프문을 작성하는 과정에서 여러 실수들을 범하지 않는다면,
이는 취향의 문제로만 그저 고이 접어두고 싶다.
위 예제의 출력문 같은 경우엔 아래와 같이 std::copy() 를 이용해 단순화 시킬 수 있다.
vector< int > v;
// operates for v...
// 상당히 깔끔해 지긴 했으나, 출력 반복자에 대해 사용자가 확실히 이해하고 있어야 한다.
copy(v.begin(), v.end(), ostream_iterator<int>(cout, "\n");
'책 스터디' 카테고리의 다른 글
[Exceptional C++ Style] 18. 클래스 가상성 (0) | 2023.04.10 |
---|---|
[Exceptional C++ Style] 16. private는 얼마나 비공개적인가? (1) | 2023.04.10 |
[Exceptional C++ Style] 14. 클래스 객체 생성(소멸) 순서의 중요성 (0) | 2023.04.10 |
[Exceptional C++ Style] 8. 템플릿 친구 만들기 (friend function template) (0) | 2023.04.10 |
[Exceptional C++ Style] 7. 함수 템플릿을 특수화하지 말아야 하는 이유 (0) | 2023.04.10 |