개요
최근 알고리듬 문제 풀이 공부를 하면서 C++로 진행하고 있다. 이때 vector라는 가변 길이 배열을 자주 사용하게 되는데 이를 처음 선언하고 다룰 때 어떻게 하는 것이 가장 좋을까라는 고민을 하게 되었다. 여기에는 capacity와 size가 있는데 이것은 무엇이며, resize와 reserve가 있는데 이는 언제 사용하는지, 왜 사용하는지 알아보자. (프로그램은 mac m1 환경에서 실행 되었으며, 예시에서 대부분 main와 헤더 파일 등 공통적인 부분은 생략합니다)
vector의 생성자와 capacity, size 변경 방법.
기본 생성자
vector의 기본 생성자는 다음과 같이 호출할 수 있다.
vector<int> arr;
vector는 push_back이라는 메서드로 삽입 순서를 기억한 상태로 자료를 저장할 수 있으며, 그때마다 capacity와 size에 영향을 준다.
여기에서 capacity는 데이터를 수용할 수 있는 메모리 공간의 개수이며, size는 현재 의미 있는 데이터가 저장되어 있는 메모리 공간의 개수이다.
다음의 예시에서 코드와 출력을 함께 보며 알아보자
vector<int> arr;
cout << "[0] arr의 capacity: " << arr.capacity() << ", arr의 size: " << arr.size() << '\n';
arr.push_back(1);
cout << "[1] arr의 capacity: " << arr.capacity() << ", arr의 size: " << arr.size() << '\n';
arr.push_back(2);
cout << "[2] arr의 capacity: " << arr.capacity() << ", arr의 size: " << arr.size() << '\n';
arr.push_back(3);
cout << "[3] arr의 capacity: " << arr.capacity() << ", arr의 size: " << arr.size() << '\n';
arr.push_back(4);
cout << "[4] arr의 capacity: " << arr.capacity() << ", arr의 size: " << arr.size() << '\n';
arr.push_back(5);
cout << "[5] arr의 capacity: " << arr.capacity() << ", arr의 size: " << arr.size() << '\n';
// 출력물
// [0] arr의 capacity: 0, arr의 size: 0
// [1] arr의 capacity: 1, arr의 size: 1
// [2] arr의 capacity: 2, arr의 size: 2
// [3] arr의 capacity: 4, arr의 size: 3
// [4] arr의 capacity: 4, arr의 size: 4
// [5] arr의 capacity: 8, arr의 size: 5
이는 capacitiy가 0부터 시작하여 size가 capacity를 넘으려 할 때 두 배씩 늘어나는 것을 알 수 있다. 기본적인 기능은 이렇게 되어 있으며 추가 기능이라거나 Undefined behavior 등이 있을 수 있으므로 자세한 것은 STL 컨테이너의 코드를 까봐야 한다.
다시 돌아와서 결론적으로 C++에서 vector는 가변 배열이며 기본 생성자를 통해 공간을 할당한다고 해서 자동으로 초기화가 되지 않는다.
이때 capacity와 size 등을 다룰 수 있는 방법으로 resize와 reserve가 있다. resize와 reserve는 이미 생성자를 호출한 이후에 사용할 수 있다. 이를 살펴보자!
resize
아래는 resize의 구현부이다.
template <class _Tp, class _Allocator>
_LIBCPP_CONSTEXPR_SINCE_CXX20
void
vector<_Tp, _Allocator>::resize(size_type __sz)
{
size_type __cs = size();
if (__cs < __sz)
this->__append(__sz - __cs);
else if (__cs > __sz)
this->__destruct_at_end(this->__begin_ + __sz);
}
resize를 호출하게 되면 첫 번째 인자에 size_t 자료형의 부호 없는 정수를 받는다. 이러면 해당 정수값만큼의 size 값을 갖게 되며, 기존보다 작아지면 초기화는 없고, 기존보다 크기가 커진다면 유효한 값을 가지고 있는 공간을 제외하고 0으로 초기화 된다.
reservce
아래는 reserve의 구현부이다.
template <class _Tp, class _Allocator>
_LIBCPP_CONSTEXPR_SINCE_CXX20
void
vector<_Tp, _Allocator>::reserve(size_type __n)
{
if (__n > capacity())
{
if (__n > max_size())
this->__throw_length_error();
allocator_type& __a = this->__alloc();
__split_buffer<value_type, allocator_type&> __v(__n, size(), __a);
__swap_out_circular_buffer(__v);
}
}
기본적으로 resize와 동일하게 size_t 자료형의 부호 없는 정수가 들어오고, 기존의 capacity보다 작은 정수형이라면 아무런 일이 일어나지 않으며, 이 보다 크면 힙 메모리에 공간을 예약한다.. max_size() 보다 큰 수가 들어오면 예외가 발생한다. 참고로 내 환경에서 max_size()는 4611686018427387903이다. 그리고 새로운 vector를 생성하며 기존의 값을 복사하는 과정을 거친다.
size_t 자료형의 정수 N을 받는 생성자
vector<int> arr(N);
이는 자동으로 N만큼의 값을 할당하며, 즉 capacity 값을 N으로 하며, 해당 vector의 요소 값을 0으로 초기화하며 size도 N이 되어버린다. 즉, 어떤 값이 유효한 값인지 모르는 상황이 된다. 결론적으로만 보면 vector의 기본 생성자를 호출한 뒤에 resize를 호출한 것 같은 결과가 나온다.
vector를 사용할 때 무엇을 사용해야 할까?
나는 여기에서 1억 개의 요소를 다뤄보는 것으로 가정했으며 micro seconds 기준으로 1) size_t 정수형을 받는 생성자, 2) resize, 3) reserve를 비교해봤다.
// 4. 성능 비교를 위한 큰 크기 테스트
cout << "\n=== 큰 크기 (100,000,000) 성능 비교 ===" << endl;
const int BIG_N = 100000000;
auto big_start2 = high_resolution_clock::now();
vector<int> big_arr2;
big_arr2.resize(BIG_N);
auto big_end2 = high_resolution_clock::now();
cout << "1. resize(N): " << duration_cast<microseconds>(big_end2 - big_start2).count() << " microseconds" << endl;
auto big_start3 = high_resolution_clock::now();
vector<int> big_arr3;
big_arr3.reserve(BIG_N);
auto big_end3 = high_resolution_clock::now();
cout << "2. reserve(N): " << duration_cast<microseconds>(big_end3 - big_start3).count() << " microseconds" << endl;
auto big_start1 = high_resolution_clock::now();
vector<int> big_arr1(BIG_N);
auto big_end1 = high_resolution_clock::now();
cout << "3. vector<int> arr(N): " << duration_cast<microseconds>(big_end1 - big_start1).count() << " microseconds" << endl;
=== 적당히 큰 크기 (요소 1억 개) 성능 비교 ===
1. resize(N): 644445 microseconds
2. reserve(N): 28 microseconds
3. vector<int> arr(N): 608951 microseconds
결과를 보면 reserve < size_t N을 받는 생성자 < resize 순으로 큰 것을 확인할 수 있다. 즉 reserve는 메모리를 할당만 하고 초기화를 하지 않아 시간이 적게 걸리고, 생성자는 생성과 동시에 초기화를 하기 때문에 시간이 절약되며, resize는 생성 후 다시 0으로 초기화 하는 작업을 갖기 때문에 시간이 더 걸린다고 유추할 수 있다.
결론
즉, vector 생성 후 재할당을 하지 않기 위해 적당한 값을 정하며, push_back을 호출하며 값을 다룰 것이라면 reserve를 사용하는 것이 좋다. 하지만 reserve는 값을 할당만 한 것이지, 값을 초기화한 것이 아니기 떄문에 arr[i]과 같이 초기화 하지 않은 요소 i번째를 접근하면 out of memory가 발생하거나 정의되지 않았기 때문에 어떤 환경에서는 잘 될 수도 있다. 그러므로 함부로 접근하면 안 되는 행동이다. arr.at(i)를 해보면 바로 out of memory가 발생한다.
참고로 STL에서는 new와 delete를 사용자가 따로 관리해주지 않아도 알아서 메모리 관리를 한다. Java의 가비지 콜렉터와 비슷한 역할이라고 보면 된다.
예전에 POCU 아카데미라는 곳에서 C++ 강의를 수강했었는데 그때 강사님께서도 reserve의 쓰임새와 실무에서의 실용성을 말씀해주셨다. 이것을 이제 PS를 통해서 다시 만나 반갑고, 필요와 용도에 따라 적절히 사용한다면 좋은 퍼포먼스를 낼 수 있을 것이다.
참고로 아래 URL은 제가 수강했던 정말 질 좋은 C++ 강의인 POCU 아카데미입니다. 이 강의 덕분에 C++을 단순히 언어로써가 아니라 메모리의 관점에서, low-level의 관점에서 이해할 수 있었습니다. 아직 C++을 잘 모르시거나, 더 깊은 이해를 원하신다면 수강해봐도 좋을 것 같습니다! 끝~
'개발 일지 > 알고리즘' 카테고리의 다른 글
| [구름톤 챌린지, JAVA] 1주차 합 계산기 (1) | 2024.02.07 |
|---|---|
| [구름톤 챌린지, JAVA] 1주차 프로젝트 매니징 (1) | 2024.02.06 |
| [구름톤 챌린지, JAVA] 1주차 운동 중독 플레이어 (0) | 2024.02.02 |