본문 바로가기
Topos Uranos

요약

std::array 타입은 원소가 몇 개일지를 컴파일할 때 알아야 한다. 그러나 대부분의 문제는 데이터가 도착할 때까지 그것을 말해 주지 않는다. 이 수업은 데이터에 따라 자라는 시퀀스인 std::vector 타입을 소개한다. 그것을 만드는 법, push_back 함수로 원소를 덧붙이는 법, 그리고 그것으로 길이를 모르는 계열을 읽는 법이다. 이어서 컬렉션을 다루는 모든 프로그래밍의 세 가지 표준 순회(누적, 필터링, 변환)를 반복문과 라이브러리의 알고리즘으로 쓴다. 또 벡터를 함수에 전달하는 법과, 새 벡터를 반환하는 것이 왜 값싼지를 다룬다. 마지막은 벡터의 내부이다. 벡터의 크기와 용량, 가득 차면 두 배 크기의 다른 메모리 블록으로 교체되는 블록, 그리고 그 때문에 해제된 메모리를 가리킨 채 남을 수 있는 원소 참조가 그것이다.

학습 목표

  1. std::vector 타입의 벡터를 만들고, push_back 함수로 원소를 덧붙이고 순회하며, 그것으로 길이를 모르는 데이터 계열을 읽을 수 있다.
  2. 벡터를 읽기 위해서는 상수 참조로, 수정하기 위해서는 참조로 받고, 결과가 시퀀스일 때는 새 벡터를 반환하는 함수를 쓸 수 있다.
  3. 누적, 필터링, 변환의 순회를 구현하고, 문제에서 그에 맞는 패턴을 알아보며, 라이브러리가 허용할 때는 std::ranges::count 함수, std::ranges::max 함수, std::ranges::fold_left 함수로 줄여 쓸 수 있다.
  4. 벡터의 크기와 용량의 차이, 그리고 다른 원소를 덧붙일 때 원소 참조가 무효화될 수 있는 이유를 예를 들어 설명할 수 있다.

자라는 시퀀스

벡터를 만들고 채우기

배열 수업은 인덱스가 있는 시퀀스를 소개했다. 같은 자료형의 원소가 하나씩 차례로 놓이고 0부터 번호가 매겨진 것이다. 그 고정 크기의 현대적 형태인 std::array<int, 7> 타입은 크기를 타입 안에 담고 있으며, 컴파일러가 그 크기를 알아야 한다. 그런데 한 반의 성적 평균을 내는 프로그램을 쓰는 사람은 학생이 몇 명일지 모른다. 여기서 크기가 또 하나의 데이터이며 실행 중에 바뀌는 시퀀스가 필요해진다. 그것이 벡터, 곧 <vector> 헤더의 std::vector<T> 타입이며, 여기서 T 기호는 원소의 자료형이다.

C++ 프로그램
#include <print>#include <vector>​int main(){    std::vector<int> squares{};    std::println("시작할 때: 크기 {}, 비어 있음: {}", squares.size(), squares.empty());    for (int k{1}; k <= 5; ++k) {        squares.push_back(k * k);    }    std::println("{}", squares);    std::println("크기 {}, 첫 원소 {}, 마지막 원소 {}", squares.size(), squares[0], squares.back());    squares[1] = 0;    squares.pop_back();    std::println("{}", squares);}
출력
시작할 때: 크기 0, 비어 있음: true[1, 4, 9, 16, 25]크기 5, 첫 원소 1, 마지막 원소 25[1, 0, 9, 16]

6번 줄은 정수의 빈 벡터를 만든다. 배열과 달리 원소가 몇 개일지 미리 밝힐 필요가 없다. size() 함수는 원소가 몇 개인지 반환하고, empty() 함수는 원소가 하나도 없는지 알려 준다. 8번부터 10번까지의 줄의 반복문은 push_back 함수로 제곱수 다섯 개를 덧붙인다. 이 함수는 인수를 끝에 놓고 크기를 하나 늘린다. 11번 줄은 지난 수업에서 배열에 쓴 범위 서식으로 벡터 전체를 출력한다. 인덱스에 의한 접근은 늘 쓰던 그대로이며, squares.back() 호출은 squares[squares.size() - 1] 식의 줄임이다. 13번 줄은 이미 있는 원소에 대입하고, 14번 줄은 pop_back 함수로 마지막 원소를 없앤다. 이 수업의 연산을 요약하면 아래와 같다.

  • v.push_back(x) 호출은 x 값을 끝에 덧붙인다. v.pop_back() 호출은 마지막 원소를 없앤다(벡터가 비어 있으면 정의되지 않은 동작이다).
  • v.size() 호출의 값은 크기이며, 그 타입은 배열 수업에서 소개한, 크기를 나타내는 부호 없는 정수 std::size_t 타입이다. v.empty() 호출은 v.size() == 0 식과 같다.
  • v[i] 식과 v.at(i) 호출은 인덱스 i의 원소를 준다. 인덱스는 i < v.size() 조건을 만족해야 한다. v.front() 호출과 v.back() 호출은 첫 원소와 마지막 원소를 주며, 벡터가 비어 있지 않을 것을 요구한다.
  • v.resize(n) 호출은 벡터의 원소를 n 개로 만든다. 끝에서 남는 원소를 없애거나, 빈 중괄호로 초기화한 것처럼 초기화된 원소를 끝에 덧붙인다(수라면 0이다).

배열에서처럼 v[i] 식은 어떤 검사도 보장하지 않지만 v.at(i) 호출은 보장한다. 인덱스가 7이고 원소가 다섯 개이면, 인덱스와 크기를 밝히는 vector::_M_range_check: __n (which is 7) >= this->size() (which is 5) 메시지를 내고 프로그램을 멈춘다. 이 정지를 일으키는 장치인 예외는 C++ 프로그래밍 II에서 공부한다.

중괄호와 소괄호

이 강좌는 모든 것을 중괄호로 초기화하는데, 벡터는 중괄호를 소괄호로 바꾸면 결과가 달라지는 강좌의 첫 타입이다. 중괄호에서는 쓴 값들이 원소이다. 소괄호에서는 첫 수가 원소의 개수이고, 둘째 수가 있으면 그것이 각 원소의 값이다.

C++ 프로그램
#include <print>#include <vector>​int main(){    const std::vector<int> a{5};    const std::vector<int> b(5);    const std::vector<int> c{3, 7};    const std::vector<int> d(3, 7);    std::println("{} {} {} {}", a, b, c, d);}
출력
[5] [0, 0, 0, 0, 0] [3, 7] [7, 7, 7]

a 벡터는 원소 하나, 곧 5를 가진다. b 벡터는 0 다섯 개, c 벡터는 3과 7, d 벡터는 7 세 개를 가진다. 따라서 중괄호 안의 목록은 “이것들이 원소이다”라고 말하며, 소괄호는 0에서 시작하는 카운터의 벡터처럼 같은 값의 복사본 n 개를 요구할 때를 위해 남겨 둔다.

빈 벡터의 마지막 원소

빈 벡터는 정당한 경우이며(데이터가 없는 입력, 아무것도 통과하지 못하는 필터), 크기가 들어간 모든 식은 이 경우에 대해 따져 보아야 한다. 가장 교활한 것은 v.size() - 1 식이다. v.size() 호출의 값은 부호 없는 정수이므로, 그 값이 0일 때 1을 빼면 −1-1이 되지 않고, 정수와 그 표현 수업에서 본 것처럼 한 바퀴 돌아 std::size_t 타입의 최댓값이 된다.

C++ 프로그램
#include <cstddef>#include <print>#include <vector>​int main(){    const std::vector<int> values{};    std::println("values.size() - 1 = {}", values.size() - 1);    // 잘못된 상한: 벡터가 비어 있으면 조건이 참이다.    for (std::size_t i{0}; i <= values.size() - 1; ++i) {        std::println("{}", values[i]);    }}
출력
values.size() - 1 = 18446744073709551615/usr/include/c++/15/bits/stl_vector.h:1282: constexpr std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::const_reference std::vector< <template-parameter-1-1>, <template-parameter-1-2> >::operator[](size_type) const [with _Tp = int; _Alloc = std::allocator<int>; const_reference = const int&; size_type = long unsigned int]: Assertion '__n < this->size()' failed.

시스템이 신호 6(으)로 프로그램을 중지했습니다.

출력의 첫 줄은 18 446 744 073 709 551 61518\,446\,744\,073\,709\,551\,615, 곧 264−12^{64} - 1의 값이며, 이 플랫폼에서 std::size_t 타입의 최댓값이다. 여기서 반복문의 조건 0 <= 18446744073709551615 식은 참이고, 첫 회차는 원소 0이 없는 벡터의 values[0] 원소를 읽는다는 결론이 나온다. 정의되지 않은 동작이다. 무엇이 관찰되는지는 배열에서처럼 옵션에 따라 다르다. 최적화 없이 컴파일하면 GCC 15의 라이브러리가 operator[] 연산자의 인덱스를 검사하고, __n < this->size() 조건을 알리는 단언으로 프로그램을 멈춘다. 최적화하면 이 검사가 사라지고, 제때 멈추지 않는 반복문은 원소가 하나도 없는 곳을 읽으려 한다(Linux에서 -O2 옵션으로 확인했다. 빈 벡터에는 블록이 없으므로, 프로그램은 원인을 설명하는 메시지 없이 세그멘테이션 오류로 끝난다). 바로잡으려면 상한을 뺄셈 없이 i < values.size() 조건으로 쓰면 된다. 이 조건은 빈 벡터에서 처음부터 거짓이다. 뺄셈이 필요해 보이는 곳에서는 먼저 values.empty() 호출로 물어보아야 한다.