etc

[C++] 배열, std::vector, std::array 비교

조부장 2020. 8. 21. 18:21

가장 기본적인 자료구조는 바로 배열입니다. 배열은 메모리에 연속적으로 배치돼 랜덤 액세스가 가능합니다. 즉 인덱스로 탐색시 시간복잡도가 O(1)입니다.

C++에서 배열을 사용할 때 여러가지 선택지가 있습니다. 그냥 배열(=C-style array)을 쓸 수도 있고, std::vector, std::array를 사용할 수도 있는데 세가지 방법이 어떤 차이가 있는지, 언제 쓰면 좋은지 알아보겠습니다.

 

1. 배열 (C-style array)

int arr1[] = {1, 2, 3};
int arr2[3] = {1, 2, 3};
int arr3[]{ 1, 2, 3 };

배열을 선언하는법은 위와 같습니다. 배열의 크기를 대괄호 안에 넣어줘야 하는데, 선언하면서 초기화까지 하는 경우 배열 크기를 생략해도 무관합니다. 이때 배열의 크기는 초기화하는 원소의 개수가 됩니다.

배열 원소들의 인덱스는 0부터 시작합니다. 즉 3칸짜리 배열이 있으면 [0], [1], [2]로 접근합니다. 

int sz;
cin >> sz;
int arr[sz];	// compile error

배열은 기본적으로 스택메모리에 할당됩니다. 스택메모리에 할당할 때는 컴파일 타임에 메모리 크기가 계산되어야 하기 때문에 배열 크기로 상수식(constexpr; constant expression)을 넣어줘야 합니다. 위와 같은 예제는 보통 컴파일러에서는 컴파일 에러를 냅니다.

#include <iostream>
using namespace std;

int main() {
	int arr[3] = { 1, 2, 3 };
	cout << arr[3] << '\n';	// wrong

	return 0;
}

배열 범위를 초과해 접근하는 경우 어떻게 될까요? 런타임 에러가 발생할 것 같지만 사실 Undefined behavior이기 때문에 어떻게 될지 모릅니다.

배열 범위 넘어가는 값에 접근했을 때 에러가 발생하지 않고 쓰레기값이 출력된 모습
배열 범위 넘어가는 값을 출력했을 때의 모습 

위 코드를 비주얼 스튜디오에서 빌드해 실행해본 모습입니다. 런타임 에러는 발생되지 않고, 쓰레기값이 출력되고 있네요. 운이 좋아서 프로그램이 뻗지 않은 것일 뿐 문제의 소지가 있는 코드입니다.

따라서 C++에서는 배열 인덱스의 bound를 체크해주는 wrapper class를 사용하는것이 바람직합니다. 가변배열이 필요하면 std::vector, 고정 크기 배열을 사용하려면 std::array를 사용하는 것이 좋습니다.

int sz;
cin >> sz;

int* arr = new int[sz];	// ok
for (int i=0; i<sz; ++i)
	arr[i] = (i+1);
delete[] arr;

new 표현식을 사용해 배열을 동적할당할 수 있습니다. 동적할당 시에는 메모리가 힙 영역에 할당되기 때문에 배열 크기가 상수식이 아니어도 됩니다.

단, 동적할당을 할 때는 많은 주의가 필요합니다. 메모리 누수를 막기 위해 new와 delete 짝을 꼭 맞춰줘야 하고, Dangling pointer 관리도 해줘야 하고, Allocation failure 예외처리 등등 여러가지를 고려해줘야 합니다. 특별한 이유가 없다면 STL 사용을 추천합니다.

 

2. std::vector

#include <iostream>
#include <vector>
using namespace std;

int main() {
	vector<int> v = { 7, 5, 16, 8 };

	v.push_back(25);
	v.push_back(13);

	for (int n : v) {
		std::cout << n << '\n'; // 7 5 16 8 25 13
	}

	return 0;
}

std::vector(벡터)는 가변 길이 배열 클래스입니다. <vector> 헤더에 정의되어 있습니다.

vector는 배열처럼 [] 연산자로 원소에 접근할 수 있습니다. 그리고 push_back, pop_back의 연산을 통해 vector 맨 뒤에 원소를 추가/삭제할 수 있습니다. 이때의 시간복잡도는 O(1) 입니다.

insert, erase 메소드를 사용하면 vector의 맨 앞 또는 중간에 원소를 추가/삭제할 수도 있는데, 이 경우 추가/삭제할 위치 뒤에 있는 원소들을 전부 한 칸씩 앞으로 당기거나 뒤로 밀어야 하므로 O(n)의 시간복잡도를 갖습니다. 중간에 원소를 추가, 삭제하는 일이 많은 경우 vector를 쓰면 적절하지 않고, std::list를 써야 맞습니다.

// 1. vector(count, val)
vector<int> v1(3);	// 0 0 0
vector<int> v2(3, 1);	// 1 1 1

// 2. vector(iterator_first, iterator_last)
vector<int> v3(v2.begin(), v2.end());	// 1 1 1

vector의 생성자는 여러가지가 있는데 이렇게 두가지를 많이 사용합니다. 첫번째는 크기와 배열에 채울 값을 지정하는 것이고, 두 번째는 vector로 복사할 컨테이너의 시작과 끝 iterator를 이용한 것입니다.

비주얼스튜디오에서 [] 연산자를 사용했을 때 에러가 던져진 모습
VS Debug 모드에서 [] 연산자로 범위 오류가 난 경우

[] 연산자는 인덱스의 bound check를 하지 않고, at 메소드를 사용하면 bound check를 해서 잘못된 범위에 접근하는 경우 std::out_of_range 예외를 throw 합니다.

위 창은 Visual Studio에서 Debug 모드로 [] 연산자로 벡터 범위 벗어난 원소에 접근했을때의 모습입니다. []연산자는 Debug 모드에서는 예외를 던지지만, Release 모드에서는 예외를 던질수도 아닐수도 있습니다

#include <iostream>
#include <vector>
#include <stdexcept>
using namespace std;

int main() {
	vector<int> v(3);
	
	try {
		cout << v.at(10) << '\n';	// bound check
	}
	catch (const std::out_of_range& e) {
		cout << e.what() << '\n';
	}

	return 0;
}

vector에 at 메소드를 사용했을 때 범위를 넘어가는 원소에 접근하면 에러가 던져지는 모습
out_of_range 에러가 발생한 모습

반면 at() 메소드를 사용하는 경우 debug, release 관계 없이 std::out_of_range 예외를 던집니다.

 

3. std::array

std::array는 고정 길이 배열 클래스입니다. <array> 헤더에 정의되어 있습니다.

C 스타일 배열보다 나은 점은 여러가지 container operation을 지원하고, at() 메소드로 bound check가 가능하다는 점입니다.

[] 연산자를 통한 접근도 가능합니다. 대신 이때는 bound check를 하지 않습니다.

array<int, 3> arr = { 1, 2, 3 };
for (auto& c : arr)
	cout << c << '\n';	// 1 2 3
reverse_copy(arr.begin(), arr.end(), ostream_iterator<int>(cout, " "));	// 3 2 1

std::array의 사용 예시입니다. array는 템플릿 인자 2개를 넣어야 되는데 타입과 크기를 지정해줘야 합니다. 

 

정리

C++의 배열 3가지에 대해 간략하게 알아봤는데요 사실 std::valarray라는 클래스도 존재합니다. 다만 인기가 없어 잘 쓰이지 않습니다. vector로 쉽게 대체가 가능한 것이 그 원인 중 하나이고요, 사실 저도 안써봤습니다.

배열을 써야 할 때 어떤 것을 써야 하냐고 하면 답은 없습니다. 용도에 맞는 배열을 골라 에러가 안나게 사용하기만 하면 잘 쓴겁니다. 다만 C-style 배열, 그것도 동적할당은 피하라고 말하고 싶네요. 동적할당이 필요하면 C++11부터 도입된 스마트 포인터를 활용하시기 바랍니다. 메모리 관리는 정말 어렵습니다.

반응형