학생 30명의 시험 점수를 기록하고 반 평균을 구하고 싶다고 상상해 볼까요?
이 작업을 하려면 변수 30개가 필요할 겁니다. 아마 아래처럼 만들어야겠죠.
// 30개의 정수형 변수를 할당합니다 (각각 이름이 다 달라야 해요!)
int testScore1 {};
int testScore2 {};
int testScore3 {};
// ...
int testScore30 {};
만들어야 할 변수가 정말 많죠!
게다가 반 평균을 구하려면 아래처럼 엄청나게 긴 코드를 짜야 할 거예요.
int average { (testScore1 + testScore2 + testScore3 + testScore4 + testScore5
+ testScore6 + testScore7 + testScore8 + testScore9 + testScore10
+ testScore11 + testScore12 + testScore13 + testScore14 + testScore15
+ testScore16 + testScore17 + testScore18 + testScore19 + testScore20
+ testScore21 + testScore22 + testScore23 + testScore24 + testScore25
+ testScore26 + testScore27 + testScore28 + testScore29 + testScore30)
/ 30; };
이건 타이핑하기도 힘들 뿐만 아니라, 똑같은 작업을 계속 반복해야 합니다.
(게다가 숫자 하나를 실수로 잘못 쳐도 눈치채기 어렵겠죠)
그리고 이 값들을 가지고 뭔가 다른 작업(예: 화면에 점수 출력하기)을 하려면,
이 수많은 변수 이름을 처음부터 끝까지 다 다시 적어야 합니다.
자, 이제 반에 새로운 학생 한 명이 전학을 와서 프로그램을 수정해야 한다고 쳐볼까요? 우리는 전체 코드를 샅샅이 뒤져서 관련된 모든 곳에 testScore31 을 수동으로 직접 추가해야 합니다.
이미 잘 돌아가고 있는 코드를 고치는 건 언제나 새로운 버그를 만들 위험이 따릅니다.
예를 들어, 평균을 구할 때 나누는 수를 30 에서 31 로 바꾸는 걸 깜빡하기 정말 쉽겠죠!
겨우 30개의 변수만 해도 이 정도인데, 수백 개나 수천 개의 데이터를 다뤄야 한다면 어떨지 상상해 보세요. 똑같은 종류의 데이터가 여러 개 필요할 때, 이렇게 변수를 일일이 하나씩 만드는 방식은 확실히 한계가 있습니다.
데이터를 구조체(struct) 안에 묶어볼 수도 있을 거예요.
struct testScores
{
// 30개의 정수형 변수를 할당합니다 (각각 이름이 다 달라야 해요!)
int score1 {};
int score2 {};
int score3 {};
// ...
int score30 {};
}
이렇게 하면 점수들을 조금 더 깔끔하게 정리할 수 있고 함수에 데이터를 통째로 넘겨주기도 쉬워지지만, 가장 핵심적인 문제는 해결되지 않습니다. 여전히 각각의 점수 데이터를 하나하나 만들고, 일일이 다른 이름을 불러서 써야 한다는 점은 똑같거든요.
눈치채셨겠지만, C++에는 이런 골치 아픈 문제를 해결할 수 있는 방법들이 있습니다. 이번 장에서는 그중 한 가지 해결책을 소개해 드릴 거고요, 다음 장들에서는 또 다른 유용한 변형 방법들을 알아볼 예정입니다.
마트에 달걀 12개를 사러 갔다고 생각해 보세요. 달걀을 하나하나 낱개로 골라서 장바구니에 담지는 않으시죠? (안 그러시죠..?) 대신, 12개가 미리 담겨 있는 달걀 판 하나를 고를 겁니다.
여기서 달걀 판이 바로 일종의 컨테이너(용기) 로, 미리 정해진 개수(보통 6개, 12개, 24개)의 달걀을 담아두는 역할을 합니다.
시리얼은 어떨까요? 그 수많은 작은 시리얼 조각들을 찬장에 낱개로 흩뿌려 보관하고 싶진 않으실 거예요! 시리얼은 보통 종이상자에 담겨 나오는데, 이 상자 역시 컨테이너입니다. 우리는 일상생활에서 여러 개의 물건을 하나로 묶어 쉽게 관리하기 위해 이런 컨테이너들을 아주 흔하게 사용합니다.
프로그래밍에도 컨테이너가 있습니다. 아주 많을 수도 있는 여러 개의 데이터(객체)를 하나로 묶어서 쉽게 만들고 관리하기 위해서죠.
프로그래밍에서 컨테이너란 이름이 없는 여러 개의 데이터(이를 요소(element) 라고 부릅니다)를 저장할 수 있는 공간을 제공하는 데이터 타입을 말합니다.
핵심 포인트
우리는 주로 서로 관련 있는 여러 개의 값들을 한 묶음으로 다뤄야 할 때 컨테이너를 사용합니다.
사실 여러분은 이미 컨테이너를 하나 사용해 보셨습니다. 바로 문자열(string)이에요! 문자열 컨테이너는 여러 개의 '문자(character)'들을 모아서 저장해 주고, 나중에 텍스트로 출력할 수 있게 해줍니다.
#include <iostream>
#include <string>
int main()
{
std::string name{ "Alex" }; // 문자열(string)은 문자들을 담는 컨테이너입니다.
std::cout << name; // 문자열을 일련의 문자들의 연속으로 출력합니다.
return 0;
}
컨테이너 자체에는 보통 이름이 있습니다. (이름이 없으면 어떻게 코딩할 때 꺼내 쓰겠어요?) 하지만 컨테이너 '안'에 들어있는 개별 요소들에는 이름이 없습니다. 만약 요소마다 이름이 있어야 한다면, 변수가 100개일 때 각기 다른 이름 100개를 지어줘야 할 테니까요! 이렇게 요소들에게 고유한 이름을 지어주지 않아도 원하는 만큼 데이터를 마구 넣을 수 있다는 점이 정말 중요하며, 일반적인 구조체와 컨테이너를 구분 짓는 가장 큰 차이점입니다. 앞서 살펴본 testScores 처럼 평범한 구조체들은 안에 든 데이터마다 각각의 이름이 꼭 필요하기 때문에 보통 컨테이너라고 부르지 않습니다.
위의 문자열 예시를 보면, 컨테이너 자체는 name 이라는 이름을 가졌지만, 그 안에 들어있는 개별 문자들('A', 'l', 'e', 'x')에는 각각의 이름이 따로 없습니다.
그렇다면 요소들의 이름이 없는데, 대체 어떻게 꺼내 쓸 수 있을까요? 모든 컨테이너는 안에 있는 데이터를 꺼내 쓸 수 있는 각자만의 '방법'들을 제공합니다. 정확히 어떤 방법을 쓰는지는 컨테이너의 종류마다 다릅니다. 이 부분은 다음 레슨에서 첫 번째 예시와 함께 자세히 알아볼 거예요.
핵심 포인트
컨테이너 안의 요소들은 각자의 이름이 없습니다. 덕분에 프로그래머가 일일이 이름을 지어주는 수고를 하지 않아도, 컨테이너 안에 원하는 만큼 많은 데이터를 넣을 수 있습니다.각 컨테이너는 이 이름 없는 데이터들에 접근할 수 있는 고유한 방법을 제공하지만, 그 방법은 컨테이너의 종류에 따라 다릅니다.
프로그래밍에서는 컨테이너 안에 들어있는 데이터(요소)의 개수를 주로 길이(length) 또는 카운트(count) 라고 부릅니다.
이전 레슨인 '5.7 — std::string 소개'에서 std::string 의 length 라는 기능을 사용해 문자열 컨테이너 안에 문자가 몇 개 들어있는지 알아내는 방법을 보여드렸었죠.
#include <iostream>
#include <string>
int main()
{
std::string name{ "Alex" };
std::cout << name << " has " << name.length() << " characters\n";
return 0;
}
위 코드는 이렇게 출력됩니다.
Alex has 4 characters
C++에서는 컨테이너 안의 요소 개수를 말할 때 크기(size) 라는 단어도 정말 자주 씁니다. 하지만 이건 좀 아쉬운 작명이에요. 왜냐하면 "크기(size)"라는 말은 데이터가 메모리 공간을 몇 바이트나 차지하는지(sizeof 연산자가 알려주는 값)를 뜻하기도 하거든요.
그래서 우리는 헷갈리지 않게, 컨테이너 안에 들어있는 '데이터의 개수'를 말할 때는 가급적 "길이(length)"라는 용어를 쓰고, 데이터가 메모리에서 차지하는 '저장 공간의 용량'을 말할 때는 "크기(size)"라는 용어를 쓰겠습니다.
다시 달걀 판 이야기로 돌아가 볼까요? 달걀 판으로 뭘 할 수 있을까요? 일단 달걀 판을 구해야겠죠. 그리고 뚜껑을 열어서 달걀을 하나 고를 수도 있고, 그 달걀로 마음대로 요리를 할 수도 있습니다. 판에서 달걀을 빼낼 수도 있고, 빈자리에 새 달걀을 채워 넣을 수도 있습니다. 달걀이 몇 개 남았는지 세어볼 수도 있죠.
이와 비슷하게, 프로그래밍의 컨테이너들도 보통 다음과 같은 기능들을 상당수 제공합니다.
물론 컨테이너를 관리하기 편하게 해주는 다른 추가 기능들을 제공하기도 합니다.
요즘 나오는 프로그래밍 언어들은 아주 다양한 종류의 컨테이너를 제공합니다. 이 컨테이너들은 구체적으로 어떤 기능들을 지원하는지, 그리고 그 기능이 얼마나 '빠른지(성능)'에 따라 차이가 납니다. 어떤 컨테이너는 아무 데이터나 엄청나게 빨리 찾을 수 있지만, 데이터를 중간에 넣거나 빼는 건 못할 수도 있습니다. 반대로 데이터를 넣고 빼는 건 빛의 속도인데, 데이터를 찾을 때는 처음부터 순서대로 하나씩 찾아야만 하는 컨테이너도 있죠.
모든 컨테이너는 저마다의 장점과 한계가 있습니다. 지금 내가 풀고 있는 문제에 딱 맞는 컨테이너를 고르는 것은 코드를 나중에 고치기 쉽게 만들고 전체적인 프로그램 속도를 높이는데 엄청나게 중요합니다. 이 주제는 나중에 다른 레슨에서 더 깊이 다루겠습니다.
대부분의 프로그래밍 언어(C++ 포함)에서 컨테이너는 동종(homogenous) 입니다. 말이 좀 어렵지만, 쉽게 말해 "컨테이너 안에는 모두 똑같은 자료형의 데이터만 넣어야 한다" 는 뜻입니다. (정수 컨테이너에는 정수만, 문자 컨테이너에는 문자만!)
어떤 컨테이너는 아예 들어갈 데이터 타입이 미리 정해져 있기도 합니다. (예: 문자열 컨테이너는 주로 문자(char)만 담습니다.) 하지만 대부분은 프로그래머가 직접 컨테이너에 담을 데이터 타입을 마음대로 정할 수 있습니다. C++에서는 보통 컨테이너를 '클래스 템플릿(class template)'이라는 것으로 구현해 둬서, 프로그래머가 원하는 타입을 템플릿 인자로 넘겨주면 그 타입에 맞게 알아서 변신하게끔 되어 있습니다. 이 내용은 다음 레슨에서 예시로 보여드릴게요.
덕분에 새로운 타입의 데이터를 담을 때마다 새로운 컨테이너를 굳이 또 만들 필요가 없어서 아주 유연하고 편리합니다. 그냥 기존 컨테이너에 "나 이번엔 정수 담을래!" 하고 지정만 해주면 바로 쓸 수 있거든요.
참고로 알아두세요...
동종 컨테이너의 반대말은 이종(heterogenous) 컨테이너입니다. 서로 다른 타입의 데이터들을 한 바구니에 마구 섞어 담을 수 있는 컨테이너죠. 파이썬(Python) 같은 스크립트 언어들에서 이런 이종 컨테이너를 주로 지원합니다.
C++ 표준 라이브러리(Standard Library)에는 흔히 쓰이는 컨테이너들을 만들어 모아둔 컨테이너 라이브러리(Containers library) 라는 부분이 있습니다. 이런 컨테이너 역할을 하는 클래스를 컨테이너 클래스(container class) 라고 부릅니다.
C++에서 "컨테이너"라는 단어는 일반적인 프로그래밍에서 쓰이는 의미보다 좀 더 좁은 의미로 쓰입니다. C++에서는 오직 이 '컨테이너 라이브러리' 안에 들어있는 클래스들만 공식적인 컨테이너로 인정해 줍니다. 그래서 우리는 일반적인 개념을 말할 때는 그냥 "컨테이너"라고 부르고, C++ 라이브러리 안에 있는 특정 클래스를 콕 집어 말할 때는 "컨테이너 클래스"라고 부르겠습니다.
심화 학습자를 위한 참고
다음의 타입들은 일반적인 프로그래밍 관점에서는 컨테이너가 맞지만, C++ 표준에서는 공식 컨테이너로 인정하지 않습니다.
- C 스타일 배열 (C-style arrays)
std::stringstd::vector<bool>C++에서 공식 컨테이너로 인정받으려면 여기서 명시된 모든 까다로운 조건들을 지켜야 합니다. (이 조건들에는 특정 함수들을 반드시 구현해야 한다는 내용도 있어서, C++의 공식 컨테이너는 무조건 클래스 형태여야만 합니다!) 위에 적힌 타입들은 이 모든 조건을 완벽히 지키지는 않거든요.
하지만std::string과std::vector<bool>은 대부분의 핵심 조건을 지키고 있어서 실제 상황에서는 컨테이너처럼 똑같이 동작합니다. 그래서 이들을 종종 "유사 컨테이너(pseudo-containers)"라고 부르기도 합니다.
제공되는 여러 컨테이너 클래스 중에서도 std::vector 와 std::array 가 압도적으로 가장 많이 쓰입니다. 그래서 앞으로 우리도 이 두 가지에 거의 모든 집중을 쏟을 겁니다.
다른 컨테이너 클래스들은 아주 특별한 상황에서만 가끔 쓰이는 편입니다.
배열(Array) 은 데이터들을 연속적으로 저장하는 컨테이너 자료형입니다.
"연속적"이라는 말은 데이터들이 메모리상에 중간에 빈 공간 없이 다닥다닥 붙어서 차례대로 저장된다는 뜻입니다. 배열은 이런 특징 덕분에 어떤 위치에 있는 데이터든 아주 빠르고 직접적으로 꺼내 볼 수 있습니다. 개념 자체도 아주 단순하고 쓰기 쉬워서, 서로 관련된 여러 데이터를 다뤄야 할 때 우리가 가장 먼저 선택하는 도구입니다.
C++에는 크게 세 가지 종류의 기본 배열이 있습니다: (C 스타일) 배열, std::vector 컨테이너 클래스, 그리고 std::array 컨테이너 클래스입니다.
std::array 와 헷갈리지 않게 보통 C 배열 이나 C 스타일 배열 이라고 부릅니다. ("벌거벗은 배열", "크기가 고정된 배열", "고정 배열", "내장 배열"이라고도 불러요.) 우리는 "C 스타일 배열"이라는 용어를 가장 선호하며, 세 가지 종류를 통틀어 말할 때만 "배열"이라고 부르겠습니다. 최신 프로그래밍 기준에서 볼 때, C 스타일 배열은 가끔 이상하게 동작할 때가 있고 심지어 위험하기까지 합니다. 왜 위험한지는 나중에 알아볼게요.std::vector 컨테이너 클래스입니다. std::vector 는 세 가지 배열 중에 가장 유연하고, 다른 배열들에는 없는 아주 다양하고 유용한 능력들을 많이 가지고 있습니다.std::array 컨테이너 클래스가 등장했습니다. std::vector 보다는 기능이 제한적이지만, 특히 크기가 작은 배열을 다룰 때 성능이 더 효율적일 수 있습니다.현대의 C++ 프로그래밍에서도 이 세 가지 배열은 각자의 상황과 역할에 맞게 모두 쓰이고 있기 때문에, 우리는 세 가지 모두를 적절한 깊이로 공부할 예정입니다.
다음 레슨에서는 우리의 첫 번째 컨테이너 클래스인 std::vector 를 소개하고, 이 글의 맨 처음에 얘기했던 "변수가 너무 많아지는 확장성 문제"를 어떻게 효율적으로 해결하는지 그 여정을 시작해 보겠습니다. std::vector 와 관련된 새로운 개념들을 아주 많이 배워야 하고, 중간중간 생기는 또 다른 추가적인 문제들도 해결해야 해서 여기서 시간을 꽤 많이 보낼 거예요.
좋은 점이 하나 있다면, 모든 컨테이너 클래스들은 사용하는 방법(인터페이스)이 꽤 비슷하다는 점입니다. 그래서 하나(예: std::vector)를 확실히 다룰 줄 알게 되면, 다른 것(예: std::array)을 배우는 건 훨씬 쉽습니다. 나중에 다른 컨테이너를 배울 때는 눈에 띄는 차이점들 위주로 다루고 가장 중요한 핵심만 다시 짚어보도록 할게요.
용어에 대해 짧게 정리하고 갈게요!
std::vector 는 이 두 가지 카테고리에 모두 속하기 때문에, 제가 어떤 용어를 쓰든 간에 std::vector 에도 당연히 적용되는 말이라고 생각하시면 됩니다.
std::vector 소개와 리스트 생성자이전 강의에서는 컨테이너와 배열을 함께 소개했습니다. 이번 강의에서는 이 장에서 계속 사용할 배열 타입인 std::vector 를 소개합니다. 그리고 지난 강의에서 이야기한 확장성 문제의 한 부분도 해결해 보겠습니다.
std::vector 소개std::vector 는 C++ 표준 컨테이너 라이브러리에 있는 컨테이너 클래스 중 하나로, 배열을 구현합니다. std::vector 는 <vector> 헤더에 클래스 템플릿으로 정의되어 있고, 템플릿 타입 인수가 원소의 타입을 결정합니다. 그래서 std::vector<int> 는 int 타입 원소를 담는 std::vector 를 뜻합니다.
std::vector 객체를 만드는 방법은 간단합니다.
#include <vector>
int main()
{
// 값 초기화 (기본 생성자 사용)
std::vector<int> empty{}; // int 원소 0개를 담는 vector
return 0;
}
변수 empty 는 원소 타입이 int 인 std::vector 입니다. 여기서는 값 초기화를 사용했기 때문에, 이 벡터는 비어 있는 상태(즉, 원소가 없는 상태)로 시작합니다.
원소가 하나도 없는 벡터는 지금은 별로 쓸모없어 보일 수 있습니다.
하지만 이후 강의에서 다시 보게 됩니다.
std::vector 초기화하기컨테이너의 목적은 관련된 값들을 관리하는 것이므로, 대부분의 경우에는 처음부터 값을 넣어서 만들고 싶습니다. 이때 원하는 초기값들을 넣은 리스트 초기화를 사용하면 됩니다. 예를 들면 다음과 같습니다.
#include <vector>
int main()
{
// 리스트 생성 (리스트 생성자 사용)
std::vector<int> primes{ 2, 3, 5, 7 }; // 값이 2, 3, 5, 7 인 int 원소 4개를 담는 vector
std::vector vowels { 'a', 'e', 'i', 'o', 'u' }; // 값이 'a', 'e', 'i', 'o', 'u' 인 char 원소 5개를 담는 vector. CTAD (C++17)로 원소 타입 char 를 자동 추론함 (권장)
return 0;
}
primes 에서는 원소 타입이 int 인 std::vector 를 만들겠다고 직접 지정했습니다.
초기값 4개를 넣었으므로, primes 는 값이 2, 3, 5, 7 인 원소 4개를 가집니다.
vowels 에서는 원소 타입을 직접 쓰지 않았습니다. 대신 C++17의 CTAD를 사용해서, 컴파일러가 초기값을 보고 원소 타입을 자동으로 추론하게 했습니다. 초기값 5개를 넣었으므로, vowels 는 값이 'a', 'e', 'i', 'o', 'u' 인 원소 5개를 가집니다.
이제 위 코드가 어떻게 동작하는지 조금 더 자세히 보겠습니다.
강의 13.8 에서 초기화 리스트는 중괄호 안에 쉼표로 구분된 값 목록이라고 배웠습니다.
(예: { 1, 2, 3 })
컨테이너에는 보통 리스트 생성자 라는 특별한 생성자가 있습니다.
이 생성자는 초기화 리스트를 사용해서 컨테이너 객체를 만들 수 있게 해 줍니다.
리스트 생성자는 보통 다음 세 가지를 합니다.
즉, 컨테이너에 값들의 초기화 리스트를 넘기면 리스트 생성자가 호출되고, 그 값들로 컨테이너가 만들어집니다.
모범 사례
원소 값들을 넣어 컨테이너를 만들 때는,
그 값들의 초기화 리스트와 함께 리스트 초기화를 사용하세요.
관련 내용
직접 만든 클래스에 리스트 생성자를 추가하는 방법은
23.7 -- std::initializer_list에서 다룹니다.
operator[] 로 배열 원소에 접근하기이제 원소들의 배열을 만들었으니, 각 원소에는 어떻게 접근할까요?
잠깐 비유를 들어 봅시다. 똑같이 생긴 우편함 여러 개가 나란히 있다고 생각해 보세요. 구분하기 쉽게 각 우편함 앞에는 번호가 적혀 있습니다.
첫 번째 우편함은 0, 두 번째는 1 식입니다.
그래서 “번호 0 우편함에 넣으세요”라고 하면 첫 번째 우편함을 뜻한다는 걸 알 수 있습니다.
C++에서 배열 원소에 접근하는 가장 흔한 방법은 배열 이름과 첨자 연산자 operator[] 를 함께 쓰는 것입니다. 특정 원소를 고르려면 대괄호 안에 원하는 원소를 가리키는 정수 값을 넣습니다. 이 정수 값을 첨자(subscript) 또는 보통 인덱스(index) 라고 부릅니다.
우편함 예시처럼 첫 번째 원소는 인덱스 0, 두 번째 원소는 인덱스 1 로 접근합니다.
예를 들어 primes[0] 은 primes 배열에서 인덱스 0 인 원소(첫 번째 원소)를 반환합니다. 첨자 연산자는 원소의 복사본이 아니라 실제 원소에 대한 참조를 돌려줍니다. 그래서 배열 원소에 접근한 뒤에는 일반 변수처럼 사용할 수 있습니다(예: 값을 대입하거나, 출력하거나).
인덱스가 1 이 아니라 0 부터 시작하므로, C++의 배열은 0부터 시작하는 배열이라고 합니다. 보통 우리는 1 부터 세는 데 익숙하므로, 처음에는 헷갈릴 수 있습니다.
인덱스는 실제로 배열의 첫 번째 원소로부터의 거리(offset) 입니다.
배열의 첫 번째 원소에서 시작해서 0 칸 이동하면, 여전히 첫 번째 원소에 있습니다. 그래서 인덱스 0 이 첫 번째 원소입니다.
배열의 첫 번째 원소에서 시작해서 1 칸 이동하면, 두 번째 원소에 도착합니다.
그래서 인덱스 1 이 두 번째 원소입니다.
인덱스가 절대 위치가 아니라 상대 거리라는 점은
17.9 -- Pointer arithmetic and subscripting 에서 더 자세히 다룹니다.
이 때문에 말로 설명할 때는 조금 헷갈릴 수 있습니다.
예를 들어 “배열 원소 1” 이라고 하면, 인덱스 0 인 첫 번째 원소를 뜻하는지, 인덱스 1 인 두 번째 원소를 뜻하는지 모호할 수 있습니다. 그래서 보통은 인덱스보다 순서로 말합니다(예: “첫 번째 원소”는 인덱스 0 인 원소).
예를 들어 보겠습니다.
#include <iostream>
#include <vector>
int main()
{
std::vector primes { 2, 3, 5, 7, 11 }; // 처음 5개의 소수를 저장함 (int)
std::cout << "The first prime number is: " << primes[0] << '\n';
std::cout << "The second prime number is: " << primes[1] << '\n';
std::cout << "The sum of the first 5 primes is: " << primes[0] + primes[1] + primes[2] + primes[3] + primes[4] << '\n';
return 0;
}
출력 결과:
The first prime number is: 2
The second prime number is: 3
The sum of the first 5 primes is: 28
배열을 사용하면 이제 소수 5개를 저장하기 위해 이름이 다른 변수 5개를 따로 만들 필요가 없습니다. 대신 원소가 5개인 배열 primes 하나만 만들고, 인덱스 값만 바꿔서 다른 원소에 접근하면 됩니다.
operator[] 와 배열 원소에 접근하는 다른 방법들은 다음 강의 16.3 -- std::vector and the unsigned length and subscript problem 에서 더 이야기합니다.
배열에 인덱스로 접근할 때는, 넣은 인덱스가 반드시 배열 안에 있는 유효한 원소를 가리켜야 합니다. 즉, 길이가 N 인 배열이라면 첨자는 0 부터 N-1 까지의 값이어야 합니다(양 끝 포함).
operator[] 는 범위 검사를 하지 않습니다. 즉, 인덱스가 0 부터 N-1 사이인지 확인하지 않습니다. 잘못된 인덱스를 operator[] 에 넘기면 정의되지 않은 동작이 발생합니다.
음수 인덱스를 쓰면 안 된다는 것은 비교적 기억하기 쉽습니다. 하지만 인덱스가 N 인 원소는 없다는 점은 놓치기 쉽습니다. 배열의 마지막 원소 인덱스는 N-1 이므로, 인덱스 N 을 쓰면 배열 끝 바로 다음 위치의 원소에 접근하려고 하게 됩니다.
팁
원소가N개인 배열에서 첫 번째 원소의 인덱스는0, 두 번째는1, 마지막은N-1입니다. 인덱스가N인 원소는 없습니다.첨자로
N을 사용하면 정의되지 않은 동작이 발생합니다.
(실제로는 배열에 없는N+1번째 원소에 접근하려는 것이기 때문입니다)
팁
일부 컴파일러(예: Visual Studio)는 인덱스가 유효한지 런타임assert로 검사해 줍니다. 이런 경우 디버그 모드에서 잘못된 인덱스를 사용하면 프로그램이assert에 걸려 중단됩니다. 릴리스 모드에서는 이assert가 제거되므로 성능 손해는 없습니다.
배열의 중요한 특징 중 하나는, 원소들이 메모리 안에 항상 연속으로 배치된다는 점입니다.
즉, 원소들이 메모리에서 서로 바로 옆에 붙어 있고, 중간에 빈 공간이 없습니다.
예를 들어 보겠습니다.
#include <iostream>
#include <vector>
int main()
{
std::vector primes { 2, 3, 5, 7, 11 }; // 처음 5개의 소수를 저장함 (int)
std::cout << "An int is " << sizeof(int) << " bytes\n";
std::cout << &(primes[0]) << '\n';
std::cout << &(primes[1]) << '\n';
std::cout << &(primes[2]) << '\n';
return 0;
}
작성자의 컴퓨터에서 한 번 실행했을 때 결과는 다음과 같았습니다.
An int is 4 bytes
00DBF720
00DBF724
00DBF728
이 int 원소들의 메모리 주소는 서로 4 바이트 차이가 납니다.
이것은 작성자의 컴퓨터에서 int 의 크기와 같습니다.
이 말은 배열이 원소마다 추가로 드는 숨은 비용 이 없다는 뜻입니다.
또, 컴파일러가 배열 안의 어떤 원소 주소든 빠르게 계산할 수 있게 해 줍니다.
관련 내용
첨자 접근이 어떤 계산으로 이루어지는지는
17.9 -- Pointer arithmetic and subscripting에서 설명합니다.배열은 몇 안 되는 임의 접근(random access) 이 가능한 컨테이너입니다.
즉, 컨테이너 안의 어떤 원소든 바로 접근할 수 있습니다.
(반대로 순차 접근(sequential access)은 정해진 순서대로만 접근해야 합니다)배열 원소에 대한 임의 접근은 보통 빠르고, 배열을 아주 쓰기 쉽게 만들어 줍니다. 이것이 배열이 다른 컨테이너보다 자주 선호되는 중요한 이유 중 하나입니다.
std::vector 만들기사용자에게 값 10개를 입력받아 std::vector 에 저장한다고 생각해 봅시다.
이 경우, 아직 값을 넣기 전이라도 길이가 10 인 std::vector 가 먼저 필요합니다.
그럼 어떻게 해야 할까요?
이렇게 초기화 리스트에 자리 채움용 값 10개를 넣어 만들 수도 있습니다.
std::vector<int> data { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; // int 값 10개를 담는 vector
하지만 이 방법은 여러 이유로 좋지 않습니다.
타이핑이 너무 많고, 초기값이 몇 개인지 한눈에 보기 어렵습니다.
나중에 개수를 바꾸고 싶어도 수정하기 불편합니다.
다행히 std::vector 에는 std::size_t 값 하나를 받아서, 그 길이의 std::vector 를 만드는 explicit 생성자(explicit std::vector<T>(std::size_t))가 있습니다.
std::vector<int> data( 10 ); // int 원소 10개를 담는 vector, 값 초기화되어 0 이 됨
이렇게 만들어진 각 원소는 값 초기화됩니다.
int 의 경우에는 0 으로 초기화되고, 클래스 타입이면 기본 생성자가 호출됩니다.
하지만 이 생성자를 사용할 때 주의할 점이 하나 있습니다.
이 생성자는 반드시 직접 초기화로 호출해야 합니다.
왜 이전 생성자를 직접 초기화로 호출해야 하는지 이해하려면, 다음 정의를 보세요.
std::vector<int> data{ 10 }; // 이것은 무엇을 할까?
이 초기화는 서로 다른 두 생성자와 맞을 수 있습니다.
{ 10 } 을 초기화 리스트로 해석하면,
리스트 생성자와 매칭되어 길이가 1이고 값이 10 인 벡터 가 됩니다.
{ 10 } 을 중괄호로 감싼 단일 초기화 값으로 해석하면, std::vector<T>(std::size_t) 생성자와 매칭되어 길이가 10 이고 각 원소가 0 으로 값 초기화된 벡터 가 됩니다.
보통 클래스 타입 초기화가 둘 이상의 생성자와 맞으면 모호하다고 판단되어 컴파일 오류가 납니다. 하지만 C++에는 이 경우를 위한 특별 규칙이 있습니다.
초기화 리스트가 비어 있지 않으면, 맞는 리스트 생성자가 다른 생성자보다 우선 선택됩니다.
이 규칙이 없으면, 리스트 생성자는 인수를 하나만 받는 다른 생성자들과 자주 충돌하게 됩니다.
{ 10 } 은 초기화 리스트로 해석될 수 있고, std::vector 에는 리스트 생성자가 있으므로 이 경우에는 리스트 생성자가 우선됩니다.
초기화 리스트를 사용해 클래스 타입 객체를 만들 때는 다음 규칙이 적용됩니다.
이 차이를 더 분명히 보기 위해, 복사 초기화, 직접 초기화, 리스트 초기화를 비슷한 예제로 비교해 보겠습니다.
// 복사 초기화
std::vector<int> v1 = 10; // 10 은 초기화 리스트가 아님. 복사 초기화는 explicit 생성자와 매칭되지 않음: 컴파일 오류
// 직접 초기화
std::vector<int> v2(10); // 10 은 초기화 리스트가 아님. explicit 단일 인수 생성자와 매칭됨
// 리스트 초기화
std::vector<int> v3{ 10 }; // { 10 } 은 초기화 리스트로 해석됨. 리스트 생성자와 매칭됨
// 복사 리스트 초기화
std::vector<int> v4 = { 10 }; // { 10 } 은 초기화 리스트로 해석됨. 리스트 생성자와 매칭됨
std::vector<int> v5({ 10 }); // { 10 } 은 초기화 리스트로 해석됨. 리스트 생성자와 매칭됨
// 기본 초기화
std::vector<int> v6 {}; // {} 는 빈 초기화 리스트. 기본 생성자와 매칭됨
std::vector<int> v7 = {}; // {} 는 빈 초기화 리스트. 기본 생성자와 매칭됨
v1 의 경우, 초기값 10 은 초기화 리스트가 아니므로 리스트 생성자와 맞지 않습니다.
단일 인수 생성자 explicit std::vector<T>(std::size_t) 도 복사 초기화에서는
explicit 생성자와 매칭되지 않으므로 사용할 수 없습니다.
결국 맞는 생성자가 없어서 컴파일 오류가 납니다.
v2 의 경우, 초기값 10 은 초기화 리스트가 아니므로 리스트 생성자와는 맞지 않습니다.
대신 단일 인수 생성자 explicit std::vector<T>(std::size_t) 와는 맞기 때문에 그 생성자가 선택됩니다.
v3 에서는 { 10 } 이 리스트 생성자와도 맞고 explicit std::vector<T>(std::size_t) 와도 맞을 수 있습니다. 하지만 리스트 생성자가 다른 맞는 생성자보다 우선하므로 리스트 생성자가 선택됩니다.
v4 에서는 { 10 } 이 리스트 생성자와 맞습니다. 리스트 생성자는 non-explicit 생성자이므로 복사 초기화에도 사용할 수 있습니다. 그래서 리스트 생성자가 선택됩니다.
놀랍게도 v5 는 직접 초기화처럼 보이지만, 사실은 복사 리스트 초기화의 다른 문법 입니다. 즉, v4 와 같은 의미입니다.
이것은 C++ 초기화 문법의 조금 불편한 점 중 하나입니다.
{ 10 } 은 리스트 생성자가 있으면 그쪽과 매칭되고, 리스트 생성자가 없으면 단일 인수 생성자와 매칭됩니다. 즉, 리스트 생성자가 존재하느냐에 따라 동작이 달라집니다.
일반적으로 컨테이너에는 리스트 생성자가 있다고 생각하면 됩니다.
경고
어떤 클래스에 리스트 생성자가 없을 때 비어 있지 않은 초기화 리스트로 객체를 만들고 있었다면, 나중에 그 클래스에 리스트 생성자가 추가될 경우 어떤 생성자가 호출되는지가 바뀔 수 있습니다.
v6와v7은 둘 다 빈 초기화 리스트로 초기화됩니다.
이 경우에는 기본 생성자가 우선됩니다.정리하면, 리스트 초기화는 보통 원소 값 목록으로 컨테이너를 초기화하기 위해 만들어진 문법이며, 실제로도 그 목적에 사용하는 것이 맞습니다.
대부분의 경우 우리가 원하는 것도 바로 그것입니다.
따라서10이 원소 값 이라면{ 10 }이 맞습니다.
하지만10이 컨테이너의 비리스트 생성자에 넘길 인수 라면 직접 초기화를 사용해야 합니다.
모범 사례
컨테이너(또는 리스트 생성자가 있는 타입)를 만들 때,
초기값이 원소 값이 아니라면 직접 초기화를 사용하세요.
팁
std::vector가 클래스 타입의 멤버일 때는,std::vector의 길이를 어떤 초기값으로 설정하는 기본 초기화를 어떻게 써야 할지 바로 떠올리기 어렵습니다.#include <vector> struct Foo { std::vector<int> v1(8); // 컴파일 오류: 멤버 기본 초기화에서는 직접 초기화를 사용할 수 없음 };이 코드는 동작하지 않습니다.
멤버 기본 초기화에서는 직접 초기화(괄호 초기화)를 사용할 수 없기 때문입니다.클래스 타입 멤버에 기본 초기값을 줄 때는 다음 규칙이 있습니다.
- 복사 초기화 또는 리스트 초기화(직접 리스트 초기화 / 복사 리스트 초기화)만 사용할 수 있습니다.
- CTAD는 사용할 수 없습니다(즉, 원소 타입을 직접 써야 합니다).
해결 방법은 다음과 같습니다.
struct Foo { std::vector<int> v{ std::vector<int>(8) }; // 가능 };이 코드는 원소 8개짜리
std::vector를 만든 다음,
그것을v의 초기값으로 사용합니다.
const 와 constexpr std::vectorstd::vector 타입 객체는 const 로 만들 수 있습니다.
#include <vector>
int main()
{
const std::vector<int> prime { 2, 3, 5, 7, 11 }; // prime 과 그 원소들은 수정할 수 없음
return 0;
}
const std::vector 는 반드시 초기화되어야 하고, 한 번 만들어진 뒤에는 수정할 수 없습니다. 이런 벡터의 원소들도 const 인 것처럼 취급됩니다.
하지만 std::vector 의 원소 타입 자체를 const 로 정의하면 안 됩니다.
(예: std::vector<const int> 는 허용되지 않음)
핵심 아이디어
표준 라이브러리 컨테이너는 원소 자체를const로 가지도록 설계되지 않았습니다.컨테이너의 상수성은 원소를
const로 만드는 데서 오는 것이 아니라,
컨테이너 자체를const로 만드는 것 으로 결정됩니다.
std::vector의 큰 단점 중 하나는constexpr로 만들 수 없다는 점입니다.constexpr배열이 필요하다면std::array를 사용하세요.
vector 일까?사람들이 일상적으로 “vector” 라는 말을 쓰면, 보통 크기와 방향을 가진 기하학적 벡터 를 떠올립니다. 그런데 std::vector 는 그런 벡터가 아닌데 왜 이런 이름이 붙었을까요?
책 From Mathematics to Generic Programming 에서 Alexander Stepanov는 대략 이렇게 말했습니다.
“STL의 vector 라는 이름은 이전 프로그래밍 언어인 Scheme과 Common Lisp에서 가져왔다. 하지만 이것은 수학에서 훨씬 오래전부터 쓰이던 vector의 의미와 맞지 않았다. 이 자료구조는 사실 array 라고 불렸어야 했다. 안타깝게도 이런 실수를 한 번 하면, 그 결과는 아주 오랫동안 남을 수 있다.”
즉, 쉽게 말해 std::vector 라는 이름은 사실 조금 잘못 붙은 이름이지만,
이제는 바꾸기엔 너무 늦었다는 뜻입니다.
이전 레슨인 '16.2 -- std::vector 및 리스트 생성자 소개'에서는 배열의 인덱스를 지정하여 요소에 접근할 수 있게 해주는 operator[]에 대해 알아보았습니다.
이번 레슨에서는 배열 요소에 접근하는 다른 방법들과 함께, 컨테이너 클래스의 길이
(현재 컨테이너 클래스에 포함된 요소의 개수)를 구하는 몇 가지 다양한 방법들을 살펴볼 것입니다.
하지만 그 전에, C++ 설계자들이 저지른 한 가지 큰 실수에 대해, 그리고 그 실수가 C++ 표준 라이브러리의 모든 컨테이너 클래스에 어떤 영향을 미치는지 먼저 이야기해 보아야 합니다.
하나의 전제로 시작해 보겠습니다. 배열의 인덱스를 지정하는 데 사용되는 데이터 타입은 배열의 길이를 저장하는 데 사용되는 데이터 타입과 일치해야 합니다. 이는 가능한 가장 긴 배열의 모든 요소를 인덱싱할 수 있게 하고, 그 범위를 벗어나지 않도록 하기 위함입니다.
비야네 스트로우스트룹(Bjarne Stroustrup)이 회고하듯, C++ 표준 라이브러리의 컨테이너 클래스들이 설계될 당시(1997년경), 설계자들은 길이(와 배열 인덱스)를 부호 있는(signed) 타입으로 할지, 아니면 부호 없는(unsigned) 타입으로 할지 결정해야 했습니다. 그들은 부호 없는 타입을 선택했습니다.
그 이유는 다음과 같았습니다.
돌이켜 보면, 이는 일반적으로 잘못된 선택이었다고 평가받습니다. 오늘날 우리는 다음과 같은 사실을 알고 있습니다:
operator[]는 어차피 범위 검사를 수행하지도 않습니다.이전 레슨인 '4.5 -- 부호 없는 정수, 그리고 이를 피해야 하는 이유'에서 우리는 수량을 저장할 때 부호 있는 값을 선호하는 이유에 대해 논의했습니다. 또한 부호 있는 값과 부호 없는 값을 섞어 쓰는 것은 예기치 않은 동작을 유발하는 지름길이라는 점도 언급했습니다. 따라서 표준 라이브러리 컨테이너 클래스들이 길이(그리고 인덱스)에 부호 없는 값을 사용한다는 것은 큰 문제입니다. 이러한 타입들을 사용할 때 부호 없는 값을 사용하는 것을 피할 수 없게 만들기 때문입니다.
당분간 우리는 과거의 이 결정과 그로 인해 발생하는 불필요한 복잡성을 안고 갈 수밖에 없습니다.
constexpr인 경우는 예외입니다.더 진행하기 전에, 부호 변환(부호 있는 타입에서 부호 없는 타입으로, 또는 그 반대로의 정수 변환)과 관련하여 '10.4 — 축소 변환, 리스트 초기화, 그리고 constexpr 초기화' 레슨에서 다루었던 내용을 빠르게 복습해 보겠습니다. 이번 장에서 이 주제를 아주 많이 다루게 될 것이기 때문입니다.
부호 변환은 축소 변환으로 간주됩니다. 왜냐하면 부호 있는 타입이나 부호 없는 타입은 서로 반대되는 타입의 범위에 포함된 모든 값을 오롯이 담을 수 없기 때문입니다. 이러한 변환이 런타임에 수행될 때, 컴파일러는 축소 변환이 허용되지 않는 문맥(예: 리스트 초기화)에서는 오류를 발생시킵니다. 반면, 이러한 변환이 수행되는 다른 문맥에서는 경고를 띄울 수도 있고 그렇지 않을 수도 있습니다.
예를 들어 보겠습니다:
#include <iostream>
void foo(unsigned int){}
int main(){
int s { 5 };
[[maybe_unused]] unsigned int u { s }; // 컴파일 오류: 리스트 초기화는 축소 변환을 허용하지 않음
foo(s); // 경고 발생 가능: 복사 초기화는 축소 변환을 허용함
return 0;
}
위의 예제에서 변수 u의 초기화는 컴파일 오류를 발생시킵니다. 리스트 초기화를 수행할 때는 축소 변환이 엄격히 금지되기 때문입니다. 반면 foo() 호출은 복사 초기화를 수행하는데, 이는 축소 변환을 허용합니다. 이 경우 컴파일러가 부호 변환 경고를 얼마나 적극적으로 잡아내느냐에 따라 경고가 나올 수도 있고 나오지 않을 수도 있습니다. 예를 들어, GCC와 Clang 컴파일러 모두 -Wsign-conversion 플래그를 사용하면 이 상황에서 경고를 띄웁니다.
하지만, 부호를 변환하려는 값이 constexpr(상수 표현식)이고 반대 타입의 동일한 값으로 문제없이 변환될 수 있다면, 이 부호 변환은 축소 변환으로 간주되지 않습니다. 이는 컴파일러가 컴파일 타임에 이 변환이 안전하다는 것을 확실히 보장할 수 있고, 만약 안전하지 않다면 컴파일 과정을 알아서 중단시킬 수 있기 때문입니다.
#include <iostream>
void foo(unsigned int){}
int main(){
constexpr int s { 5 }; // 이제 constexpr로 선언되었습니다.
[[maybe_unused]] unsigned int u { s }; // 성공: s는 constexpr이며 안전하게 변환될 수 있으므로 축소 변환이 아닙니다.
foo(s); // 성공: s는 constexpr이며 안전하게 변환될 수 있으므로 축소 변환이 아닙니다.
return 0;
}
이 경우 s가 constexpr이고 변환할 값(5)이 부호 없는 값으로도 온전히 표현될 수 있으므로, 이 변환은 축소 변환으로 간주되지 않으며 아무런 문제 없이 암시적으로 수행될 수 있습니다.
이러한 축소 변환이 아닌 constexpr 변환(constexpr int에서 constexpr std::size_t로의 변환 등)은 우리가 앞으로 아주 유용하게, 자주 활용하게 될 핵심 개념입니다.
std::vector의 길이와 인덱스의 타입은 size_type입니다레슨 '10.7 -- Typedef와 타입 별칭(type aliases)'에서, 우리는 타입의 이름이 달라질 수 있는 경우(예: 컴파일러 구현에 따라 정의되는 경우)에 typedef와 타입 별칭을 자주 사용한다고 언급했습니다. 예를 들어 std::size_t는 보통 unsigned long이나 unsigned long long과 같이 크기가 큰 부호 없는(unsigned) 정수 타입을 위한 typedef입니다.
각각의 C++ 표준 라이브러리 컨테이너 클래스들은 size_type(때로는 T::size_type으로 표기됨)이라는 이름의 중첩된(nested) typedef 멤버를 정의합니다. 이는 해당 컨테이너의 길이(그리고 지원되는 경우 인덱스)를 나타내는 데 사용되는 타입에 대한 별칭입니다.
여러분은 보통 공식 문서나 컴파일러의 경고/에러 메시지에서 size_type을 자주 보게 될 것입니다. 예를 들어, std::vector의 size() 멤버 함수에 대한 공식 문서를 보면 size()가 size_type 타입의 값을 반환한다고 명시되어 있습니다.
관련 내용
중첩된 typedef에 대해서는 레슨 '15.3 -- 중첩 타입 (멤버 타입)'에서 다룹니다.
size_type은 거의 항상 std::size_t의 별칭이지만, (드문 경우지만) 다른 타입을 사용하도록 재정의(override)될 수도 있습니다.
핵심 포인트
size_type은 표준 라이브러리 컨테이너 클래스 내부에 정의된 중첩 typedef로, 컨테이너 클래스의 길이 및 인덱스를 나타내는 타입으로 사용됩니다.size_type의 기본값은std::size_t이며, 이 설정이 변경되는 일은 거의 없기 때문에 우리는size_type을 사실상std::size_t의 별칭이라고 가정해도 무방합니다.
심화 학습
std::array를 제외한 모든 표준 라이브러리 컨테이너는 메모리 할당을 위해std::allocator를 사용합니다. 이러한 컨테이너들의 경우,T::size_type은 사용된 할당자(allocator)의size_type에서 파생됩니다.std::allocator는 최대std::size_t바이트의 메모리를 할당할 수 있으므로,std::allocator<T>::size_type은std::size_t로 정의됩니다. 따라서T::size_type의 기본값은 자연스럽게std::size_t가 됩니다.- 사용자 정의 할당자(custom allocator)를 만들고 그 안에서
T::size_type을std::size_t가 아닌 다른 타입으로 정의한 경우에만 컨테이너의T::size_type이 달라집니다. 이는 매우 드문 일이며 특정 애플리케이션의 특수한 목적을 위해 개별적으로 이루어지는 작업입니다. 따라서 여러분의 코드가 그러한 커스텀 할당자를 사용하지 않는 한(만약 사용한다면 여러분이 직접 그 사실을 알고 있을 것입니다),T::size_type이std::size_t일 것이라고 가정하는 것이 일반적으로 안전합니다.- 컨테이너 클래스의
size_type멤버에 직접 접근할 때는 반드시 템플릿화된 컨테이너 클래스의 전체 이름으로 스코프(scope)를 명시해야 합니다.
(예:std::vector<int>::size_type)
size() 멤버 함수 또는 std::size()를 사용하여 std::vector의 길이 구하기size() 멤버 함수를 사용하여 컨테이너 클래스 객체에 현재 길이를 요청할 수 있습니다.
(이 함수는 길이를 부호 없는(unsigned) 타입인 size_type으로 반환합니다)
#include <iostream>
#include <vector>
int main(){
std::vector prime { 2, 3, 5, 7, 11 };
std::cout << "length: " << prime.size() << '\n'; // 길이를 `size_type` (std::size_t의 별칭) 타입으로 반환합니다.
return 0;
}
이 코드의 출력 결과는 다음과 같습니다.
length: 5
length()와 size() 멤버 함수를 모두 가지고 있는(두 함수는 동일한 작업을 수행합니다) std::string 및 std::string_view와 달리, std::vector를 비롯한 C++의 대부분의 다른 컨테이너 타입은 size() 함수만 가지고 있습니다. 이제 왜 컨테이너의 '길이(length)'를 종종 '크기(size)'라고 모호하게 부르는지 이해하셨을 것입니다.
C++17부터는 std::size() 비멤버 함수도 사용할 수 있습니다
(컨테이너 클래스의 경우, 이 함수는 내부적으로 단순히 size() 멤버 함수를 호출합니다).
#include <iostream>
#include <vector>
int main(){
std::vector prime { 2, 3, 5, 7, 11 };
std::cout << "length: " << std::size(prime); // C++17, 길이를 `size_type` (std::size_t의 별칭) 타입으로 반환합니다.
return 0;
}
심화 학습
std::size()는 붕괴되지 않은 C 스타일 배열에도 사용할 수 있기 때문에, 때로는size()멤버 함수를 직접 호출하는 것보다 이 방법이 더 선호되기도 합니다.특히 컨테이너 클래스나 붕괴되지 않은 C 스타일 배열을 모두 인수로 받을 수 있는 함수 템플릿을 작성할 때 아주 유용합니다.
참고: C 스타일 배열 붕괴에 대해서는 레슨 '17.8 -- C 스타일 배열 붕괴'에서 자세히 다룹니다.
위의 두 가지 방법 중 하나를 사용하여 배열의 길이를 구한 뒤, 이를 부호 있는 타입의 변수에 저장하려고 하면 부호 변환 경고나 오류가 발생할 가능성이 높습니다.
이 상황에서 가장 간단하게 해결할 수 있는 방법은 반환값을 원하는 타입으로 명시적 형변환(static_cast) 하는 것입니다.
#include <iostream>
#include <vector>
int main(){
std::vector prime { 2, 3, 5, 7, 11 };
int length { static_cast<int>(prime.size()) }; // 반환값을 int 타입으로 static_cast 합니다.
std::cout << "length: " << length ;
return 0;
}
std::ssize()를 사용하여 std::vector의 길이 구하기C++20에서는 std::ssize() 비멤버(non-member) 함수를 새롭게 도입했습니다.
이 함수는 길이를 크기가 큰 부호 있는(signed) 정수 타입(일반적으로 std::size_t의 부호 있는 짝꿍으로 자주 사용되는 std::ptrdiff_t)으로 반환합니다.
#include <iostream>
#include <vector>
int main(){
std::vector prime{ 2, 3, 5, 7, 11 };
std::cout << "length: " << std::ssize(prime); // C++20, 길이를 크기가 큰 부호 있는 정수 타입으로 반환합니다.
return 0;
}
이것은 길이를 구하는 세 가지 함수(size(), std::size(), std::ssize()) 중 유일하게 길이를 부호 있는(signed) 타입으로 반환하는 함수입니다.
이 방법을 사용하여 길이를 부호 있는 타입의 변수에 저장하고 싶다면,
두 가지 방법 중 하나를 선택할 수 있습니다.
첫째, 일반적인 int 타입은 std::ssize()가 반환하는 부호 있는 타입보다 크기가 작을 수 있습니다. 따라서 길이를 int 변수에 할당하려는 경우, 이 변환을 명확하게 하기 위해 결과값을 int로 명시적 형변환(static_cast) 해야 합니다. (그렇지 않으면 축소 변환(narrowing conversion) 경고나 컴파일 오류가 발생할 수 있습니다)
#include <iostream>
#include <vector>
int main(){
std::vector prime{ 2, 3, 5, 7, 11 };
int length { static_cast<int>(std::ssize(prime)) }; // 반환값을 int로 명시적 형변환(static_cast) 합니다.
std::cout << "length: " << length;
return 0;
}
둘째, 대안으로 auto 키워드를 사용하여 컴파일러가 해당 변수에 딱 맞는 올바른 부호 있는 타입을 스스로 추론하도록 맡길 수도 있습니다.
#include <iostream>
#include <vector>
int main(){
std::vector prime{ 2, 3, 5, 7, 11 };
auto length { std::ssize(prime) }; // std::ssize()가 반환하는 부호 있는 타입을 자동으로 추론하기 위해 auto를 사용합니다.
std::cout << "length: " << length;
return 0;
}
operator[]를 사용한 배열 요소 접근은 경계 검사를 수행하지 않습니다이전 레슨에서 첨자 연산자 operator[] 에 대해 소개했습니다.
#include <iostream>
#include <vector>
int main(){
std::vector prime{ 2, 3, 5, 7, 11 };
std::cout << prime[3]; // 인덱스가 3인 요소의 값(7)을 출력합니다.
std::cout << prime[9]; // 유효하지 않은 인덱스입니다 (미정의 동작 발생).
return 0;
}
operator[]는 경계 검사를 수행하지 않습니다.
operator[]에 들어가는 인덱스는 비상수일 수도 있습니다.
이 부분에 대해서는 이후 섹션에서 더 자세히 다루도록 하겠습니다.
at() 멤버 함수를 사용한 배열 요소 접근은 런타임 경계 검사를 수행합니다배열 컨테이너 클래스들은 배열 요소에 접근하는 또 다른 방법을 지원합니다.
at() 멤버 함수를 사용하면 런타임 경계 검사를 거쳐 배열 요소에 접근할 수 있습니다.
#include <iostream>
#include <vector>
int main(){
std::vector prime{ 2, 3, 5, 7, 11 };
std::cout << prime.at(3); // 인덱스가 3인 요소의 값을 출력합니다.
std::cout << prime.at(9); // 유효하지 않은 인덱스입니다 (예외 발생).
return 0;
}
위의 예제에서 prime.at(3) 호출은 인덱스 3이 유효한지 먼저 확인합니다. 유효한 인덱스이므로 배열의 3번 요소에 대한 참조를 반환하고, 우리는 그 값을 출력할 수 있습니다.
하지만 prime.at(9) 호출은 9가 이 배열의 유효한 인덱스가 아니기 때문에 (런타임에) 실패합니다. at() 함수는 참조를 반환하는 대신, 프로그램을 종료시키는 오류를 발생시킵니다.
심화 학습
at()멤버 함수가 범위를 벗어난 인덱스를 만나면, 실제로는std::out_of_range타입의 예외를 던집니다. 이 예외가 프로그램 내에서 적절히 처리되지 않으면 프로그램이 강제 종료됩니다. 예외와 그 처리 방법에 대해서는 27장에서 자세히 다루게 됩니다.
operator[]와 마찬가지로, at()에 전달되는 인덱스는 비상수일 수 있습니다.
매 호출마다 런타임에 경계 검사를 수행해야 하기 때문에, at()은 operator[]보다 느립니다 (물론 더 안전하긴 합니다). 더 안전함에도 불구하고 실제 현업에서는 at()보다 operator[]가 일반적으로 훨씬 더 많이 사용됩니다. 그 주된 이유는, 애초에 유효하지 않은 인덱스를 사용하려는 시도 자체를 하지 않도록 인덱싱을 하기 전에 논리적으로 미리 경계 검사를 확실히 하는 것이 더 좋은 프로그래밍 습관이기 때문입니다.
constexpr 부호 있는 정수로 std::vector 인덱싱하기constexpr int를 사용하여 std::vector를 인덱싱할 때, 우리는 이것이 축소 변환으로 취급되지 않으면서도 컴파일러가 자연스럽게 std::size_t로 암시적 변환을 수행하도록 내버려 둘 수 있습니다.
#include <iostream>
#include <vector>
int main(){
std::vector prime{ 2, 3, 5, 7, 11 };
std::cout << prime[3] << '\n'; // 성공: 3이 int에서 std::size_t로 변환되며, 축소 변환이 아닙니다.
constexpr int index { 3 }; // constexpr 선언
std::cout << prime[index] << '\n'; // 성공: constexpr 인덱스가 std::size_t로 암시적 변환되며, 축소 변환이 아닙니다.
return 0;
}
std::vector 인덱싱하기배열을 인덱싱하는 데 사용되는 첨자는 비상수일 수 있습니다.
#include <iostream>
#include <vector>
int main(){
std::vector prime{ 2, 3, 5, 7, 11 };
std::size_t index { 3 }; // 비상수(non-constexpr)
std::cout << prime[index] << '\n'; // operator[]는 std::size_t 타입의 인덱스를 예상하므로 변환이 필요하지 않습니다.
return 0;
}
하지만 우리의 모범 사례('4.5 -- 부호 없는 정수, 그리고 이를 피해야 하는 이유')에 따라, 우리는 일반적으로 수량을 저장할 때 부호 없는 타입의 사용을 피하고자 합니다.
첨자가 비상수인 부호 있는 값일 때, 우리는 다음과 같은 문제에 직면하게 됩니다.
#include <iostream>
#include <vector>
int main(){
std::vector prime{ 2, 3, 5, 7, 11 };
int index { 3 }; // 비상수(non-constexpr)
std::cout << prime[index] << '\n'; // 경고 발생 가능: 인덱스가 암시적으로 std::size_t로 변환됨, 축소 변환 발생
return 0;
}
이 예제에서 index는 비상수인 부호 있는 int입니다. std::vector의 일부로 정의된 operator[]의 첨자는 size_type(std::size_t의 별칭) 타입을 갖습니다. 따라서 prime[index]를 호출할 때, 우리가 전달한 부호 있는 int는 std::size_t로 변환되어야 합니다.
이러한 변환은 위험하지 않아야 합니다 (std::vector의 인덱스는 음수가 아닐 것으로 예상되며, 음수가 아닌 부호 있는 값은 부호 없는 값으로 안전하게 변환되기 때문입니다). 하지만 이 변환이 런타임에 수행될 때 이는 축소 변환으로 간주되며, 컴파일러는 이것이 안전하지 않은 변환이라는 경고를 띄워야 합니다 (만약 경고를 발생시키지 않는다면, 경고가 발생하도록 컴파일러 설정을 수정하는 것을 고려해야 합니다).
배열 인덱싱은 매우 흔하게 사용되는 작업이고 이러한 변환이 일어날 때마다 경고가 생성되기 때문에, 컴파일 로그가 불필요한 경고들로 금세 도배될 수 있습니다. 또는 "경고를 오류로 처리(treat warning as errors)" 설정이 활성화되어 있다면 아예 컴파일이 중단될 것입니다.
이 문제를 피할 수 있는 방법은 여러 가지가 있지만 (예를 들어, 배열을 인덱싱할 때마다 int를 std::size_t로 static_cast 하는 등), 이 모든 방법은 필연적으로 코드를 지저분하게 하거나 복잡하게 만듭니다. 이 경우 가장 간단한 방법은 std::size_t 타입의 변수를 인덱스로 사용하고, 이 변수를 인덱싱 이외의 다른 용도로는 사용하지 않는 것입니다. 그렇게 하면 애초에 비상수 변환이 일어나는 것을 피할 수 있습니다.
팁
또 다른 좋은 대안은std::vector자체를 인덱싱하는 대신,
data()멤버 함수의 결과를 인덱싱하는 것입니다:
#include <iostream>
#include <vector>
int main(){
std::vector prime{ 2, 3, 5, 7, 11 };
int index { 3 }; // 비상수 부호 있는 값
std::cout << prime.data()[index] << '\n'; // 성공: 부호 변환 경고 없음
return 0;
}
내부적으로 std::vector는 그 요소들을 C 스타일 배열에 보관합니다. data() 멤버 함수는 이 기반이 되는 C 스타일 배열에 대한 포인터를 반환하며, 우리는 이것을 인덱싱할 수 있습니다. C 스타일 배열은 부호 있는 타입과 부호 없는 타입 모두로 인덱싱하는 것을 허용하기 때문에, 부호 변환 문제에 부딪히지 않습니다. C 스타일 배열에 대해서는 레슨 '17.7 -- C 스타일 배열 소개'와 '17.8 -- C 스타일 배열 붕괴'에서 더 자세히 논의합니다.
저자의 노트 (Author’s note)
이와 같은 인덱싱 문제를 해결하기 위한 추가적인 옵션들은 레슨 '16.7 -- 배열, 루프, 그리고 부호 문제 해결책'에서 논의할 것입니다.
std::vector 전달하기std::vector 타입의 객체도 다른 평범한 데이터들처럼 함수에 쏙 집어넣을 수 있어요.
하지만 주의할 점이 있습니다! std::vector를 '값으로 전달(pass by value)'하게 되면, 벡터 안에 들어있는 모든 데이터를 통째로 복사하게 됩니다. 데이터가 많다면 컴퓨터가 아주 버거워하겠죠(비용이 큽니다).
그래서 이런 불필요하고 무거운 복사를 막기 위해, 우리는 보통 '참조로 전달(pass by reference)' 방식을 사용합니다. 원본의 위치만 알려주는 방식이죠! (보통은 데이터가 수정되지 않도록 const 참조를 씁니다.)
std::vector를 사용할 때는, 그 안에 '어떤 종류의 데이터(요소)'가 들어가는지도 벡터의 정체성(타입 정보)에 포함됩니다. 따라서 함수에서 std::vector를 받을 때는 그 안에 어떤 타입이 들어있는지 명확하게 적어주어야 해요.
#include <iostream>
#include <vector>
void passByRef(const std::vector<int>& arr) // 여기에 <int>를 명확하게 적어주어야 합니다
{
std::cout << arr[0] << '\n';
}
int main()
{
std::vector primes{ 2, 3, 5, 7, 11 };
passByRef(primes);
return 0;
}
std::vector 전달하기방금 만든 passByRef() 함수는 정수가 들어있는 std::vector<int> 만 기다리고 있기 때문에, 실수(double) 같은 다른 종류의 데이터가 들어있는 벡터는 전달할 수 없어요.
#include <iostream>
#include <vector>
void passByRef(const std::vector<int>& arr)
{
std::cout << arr[0] << '\n';
}
int main()
{
std::vector primes{ 2, 3, 5, 7, 11 };
passByRef(primes); // 성공: 이것은 std::vector<int> 입니다
std::vector dbl{ 1.1, 2.2, 3.3 };
passByRef(dbl); // 컴파일 에러: std::vector<double>을 std::vector<int>로 변환할 수 없습니다
return 0;
}
C++17 이상의 최신 버전에서는 CTAD를 써서 이 문제를 해결해보려고 할 수도 있을 거예요.
#include <iostream>
#include <vector>
void passByRef(const std::vector& arr) // 컴파일 에러: CTAD는 함수 매개변수를 추론하는 데 사용할 수 없습니다
{
std::cout << arr[0] << '\n';
}
int main()
{
std::vector primes{ 2, 3, 5, 7, 11 }; // 성공: CTAD를 사용하여 std::vector<int>로 추론합니다
passByRef(primes);
return 0;
}
하지만 안타깝게도, CTAD는 처음 벡터를 만들 때는 안에 든 값을 보고 타입을 척척 알아맞히지만, 아직 '함수의 매개변수' 자리에서는 작동하지 않는답니다.
예전에도 함수가 받는 데이터의 '타입'만 다르고 하는 일은 똑같을 때 비슷한 문제를 겪은 적이 있죠? 이럴 때 쓰기 딱 좋은 게 바로 함수 템플릿 이라는 기능이에요! 데이터 타입을 템플릿(틀)으로 만들어두면, C++이 알아서 우리가 넘겨주는 데이터에 맞춰 진짜 함수를 뚝딱 찍어내 줍니다.
관련 내용
함수 템플릿에 대해서는 '11.6 - 함수 템플릿' 강의에서 다루고 있어요.
이제 똑같은 템플릿 매개변수를 사용해서 함수 템플릿을 만들어 볼게요.
#include <iostream>
#include <vector>
template <typename T>
void passByRef(const std::vector<T>& arr)
{
std::cout << arr << '\n';
}
int main()
{
std::vector primes{ 2, 3, 5, 7, 11 };
passByRef(primes); // 성공: 컴파일러가 passByRef(const std::vector<int>&) 함수를 만들어냅니다
std::vector dbl{ 1.1, 2.2, 3.3 };
passByRef(dbl); // 성공: 컴파일러가 passByRef(const std::vector<double>&) 함수를 만들어냅니다
return 0;
}
위 예제에서는 const std::vector<T>& 타입의 데이터를 받는 passByRef() 라는 단 하나의 함수 템플릿을 만들었어요. 여기서 T 는 바로 윗줄의 template <typename T> 에서 정의된 것인데요. 이 T 는 함수를 호출하는 사람이 어떤 타입이든 자유롭게 정할 수 있게 해주는 '빈칸' 역할을 합니다.
따라서 main() 함수에서 정수형 벡터인 primes 를 넣어 호출하면, 컴퓨터가 알아서 정수형 전용 함수인 void passByRef(const std::vector<int>& arr) 를 만들어내고 실행해요.
마찬가지로 실수형 벡터인 dbl 을 넣어 호출하면, 알아서 실수형 전용 함수인 void passByRef(const std::vector<double>& arr) 를 찍어내서 실행한답니다.
결과적으로, 단 하나의 템플릿만으로도 어떤 타입, 어떤 길이의 std::vector 든 다 받아낼 수 있는 만능 함수를 만든 셈이죠!
std::vector 전달하기아예 벡터뿐만 아니라 '모든 종류의 객체'를 다 받아주는 더 강력한 템플릿을 만들 수도 있어요.
#include <iostream>
#include <vector>
template <typename T>
void passByRef(const T& arr) // [] 연산자가 오버로딩된 모든 타입의 객체를 받아줍니다
{
std::cout << arr[0] << '\n';
}
int main()
{
std::vector primes{ 2, 3, 5, 7, 11 };
passByRef(primes); // 성공: 컴파일러가 passByRef(const std::vector<int>&) 함수를 만들어냅니다
std::vector dbl{ 1.1, 2.2, 3.3 };
passByRef(dbl); // 성공: 컴파일러가 passByRef(const std::vector<double>&) 함수를 만들어냅니다
return 0;
}
C++20 버전부터는 auto 매개변수를 이용한 약식 함수 템플릿 을 써서 똑같은 작업을 더 간단하게 할 수 있습니다.
#include <iostream>
#include <vector>
void passByRef(const auto& arr) // 약식 함수 템플릿
{
std::cout << arr << '\n';
}
int main()
{
std::vector primes{ 2, 3, 5, 7, 11 };
passByRef(primes); // 성공: 컴파일러가 passByRef(const std::vector<int>&) 함수를 만들어냅니다
std::vector dbl{ 1.1, 2.2, 3.3 };
passByRef(dbl); // 성공: 컴파일러가 passByRef(const std::vector<double>&) 함수를 만들어냅니다
return 0;
}
이 두 가지 방법은 코드가 에러 없이 돌아가기만 한다면 정말 '아무 타입'이나 다 받아줍니다. std::vector 말고도 다른 데이터 타입에서 써먹고 싶은 함수를 만들 때 아주 유용하죠.
예를 들어, 위 함수들은 std::array 나 문자열인 std::string, 심지어 우리가 생각지도 못한 전혀 다른 타입에서도 잘 작동할 거예요.
하지만 단점도 있습니다. 아무거나 다 받다 보니, 문법적으로는 통과가 되더라도 실제 의미상으로는 전혀 말이 안 되는 데이터가 들어가서 나중에 골치 아픈 버그를 일으킬 위험이 있거든요.
방금 봤던 것과 비슷한 다음 템플릿 함수를 한번 살펴볼까요?
#include <iostream>
#include <vector>
template <typename T>
void printElement3(const std::vector<T>& arr)
{
std::cout << arr[3] << '\n';
}
int main()
{
std::vector arr{ 9, 7, 5, 3, 1 };
printElement3(arr);
return 0;
}
이 코드에서 printElement3(arr) 은 문제없이 잘 작동합니다.
하지만 방심한 초보 프로그래머를 노리는 무서운 함정이 하나 숨어있어요. 눈치채셨나요?
위 프로그램은 인덱스 번호가 3번인 (즉, 4번째에 있는) 값을 출력합니다.
배열 안에 4개 이상의 데이터가 있다면 아무 문제가 없죠.
하지만, 데이터가 4개도 안 되는 짧은 배열을 집어넣어도 컴퓨터(컴파일러)는 아무런 불만 없이 코드를 통과시켜 버립니다. 예를 들어볼게요.
#include <iostream>
#include <vector>
template <typename T>
void printElement3(const std::vector<T>& arr)
{
std::cout << arr[3] << '\n';
}
int main()
{
std::vector arr{ 9, 7 }; // 2개의 요소만 있는 배열 (사용 가능한 인덱스는 0과 1뿐입니다)
printElement3(arr);
return 0;
}
이렇게 되면 배열의 범위를 벗어난 곳에 접근하게 되어 프로그램이 엉뚱하게 작동하거나 뻗어버리는 정의되지 않은 동작이 발생하게 됩니다.
이럴 때 쓸 수 있는 한 가지 방법은 arr.size() 로 길이를 확인하는 검사기(assert)를 다는 거예요. 그러면 버그를 잡는 디버그 모드로 실행할 때 이런 에러를 미리 잡아낼 수 있죠. 단, std::vector 의 크기를 확인하는 기능은 프로그램이 '실행 중일 때'만 작동하기 때문에, 코드를 작성하는 시점에서는 미리 검사할 수 없다는 한계가 있습니다.
팁
사실 가장 좋은 방법은, 배열의 길이를 깐깐하게 확인해야 하는 상황이라면 아예std::vector를 쓰지 않는 것입니다. 대신 프로그램 실행 전에도 크기를 알 수 있는std::array를 쓰는 것이 훨씬 낫습니다. 그러면 코드를 짤 때 길이를 미리 검사(static_assert)할 수 있거든요. 이 내용은 나중에 '17.3 - std::array 전달 및 반환하기'에서 배울 거예요.
결론적으로 가장 좋은 해결책 은 애초에 "무조건 몇 개 이상의 데이터가 들어있는 벡터만 넣어주세요!" 라고 강요하는 함수 자체를 만들지 않는 것이랍니다.
함수에 std::vector 를 넘겨줄 때, 우리는 보통 배열 데이터 전체를 복사하는 엄청난 비용을 피하려고 '(const) 참조' 방식을 사용합니다.
그렇기 때문에, std::vector 를 반환할 때는 그냥 '값으로 반환(return by value)'해도 괜찮다는 사실을 알게 되면 아마 깜짝 놀라실 거예요.
다음 프로그램을 한 번 볼까요?
#include <iostream>
#include <vector>
int main()
{
std::vector arr1 { 1, 2, 3, 4, 5 }; // { 1, 2, 3, 4, 5 }를 arr1에 복사합니다.
std::vector arr2 { arr1 }; // arr1을 arr2에 복사합니다.
arr1[0] = 6; // 우리는 arr1을 계속 사용할 수 있습니다.
arr2[0] = 7; // 그리고 arr2도 계속 사용할 수 있습니다.
std::cout << arr1[0] << arr2[0] << '\n';
return 0;
}
arr2 가 arr1 을 이용해 초기화될 때, std::vector 의 복사 생성자가 호출되면서 arr1 의 내용이 arr2 로 고스란히 복사됩니다.
여기서는 arr1 과 arr2 가 각자 독립적으로 살아남아야 하므로, 복사본을 만드는 것이 유일하고 합리적인 방법이에요. 결과적으로 이 예제에서는 초기화할 때마다 하나씩, 총 두 번의 복사가 일어납니다.
복사 의미론(Copy semantics) 이라는 거창한 말은 사실 별게 아닙니다.
그냥 "객체의 복사본을 어떻게 만들 것인가?"를 정해둔 규칙일 뿐이에요.
어떤 타입이 복사 의미론을 지원한다고 하면, 복사하는 규칙이 잘 정해져 있어서 그 타입의 객체를 안전하게 복사할 수 있다는 뜻입니다. 그리고 "복사 의미론이 발동되었다"는 말은 우리가 객체를 복사하게 만드는 어떤 행동을 했다는 뜻이죠.
클래스 타입의 경우, 보통 복사 생성자 나 복사 대입 연산자를 통해 이 규칙을 구현합니다. 대개는 클래스 안의 모든 데이터를 하나하나 다 복사하게 만들어 둬요. 방금 본 예제에서 std::vector arr2 { arr1 }; 라는 코드가 바로 복사 규칙을 작동시킨 겁니다.
그러면 std::vector 의 복사 생성자가 불려가서 arr1 의 모든 데이터를 arr2 로 복사하죠. 결국 arr1 은 arr2 와 똑같은 내용을 가지면서도, 서로 완전히 독립적인 남남이 됩니다.
자, 이제 조금 다른 예제를 살펴볼까요?
#include <iostream>
#include <vector>
std::vector<int> generate() // 값으로 반환(return by value)합니다.
{
// 의무적인 복사 생략(mandatory copy elision)이 적용되지 않도록 일부러 이름이 있는 객체를 사용합니다.
std::vector arr1 { 1, 2, 3, 4, 5 }; // { 1, 2, 3, 4, 5 }를 arr1에 복사합니다.
return arr1;
}
int main()
{
std::vector arr2 { generate() }; // generate()의 반환값은 이 표현식이 끝날 때 사라집니다(죽습니다).
// 여기서부터는 generate()의 반환값을 사용할 방법이 전혀 없습니다.
arr2[0] = 7; // 우리는 오직 arr2에만 접근할 수 있습니다.
std::cout << arr2[0] << '\n';
return 0;
}
이번에 arr2 를 초기화할 때는, generate() 함수가 반환하는 임시 객체를 사용하고 있습니다. 이전 예제에서는 나중에도 계속 쓸 수 있는 이름 있는 변수(lvalue)를 썼지만, 이번에는 다릅니다. 이 임시 객체는 한 번 쓰이고 나면 이 줄이 끝날 때 바로 파괴되어 버리는 1회용(rvalue)이거든요. 어차피 이 임시 객체와 그 안의 데이터가 곧 사라질 운명이니, 우리는 어떻게든 그 데이터를 살려내서 arr2 안으로 가져와야만 합니다.
보통 이럴 때 우리가 하던 방식은 첫 번째 예제와 같습니다.
그냥 복사 규칙을 써서 무겁고 비싼 복사를 한 번 더 하는 거죠. 그렇게 하면 임시 객체가 파괴되더라도 arr2 가 자기만의 데이터를 안전하게 가질 수 있으니까요.
하지만 이전과 결정적으로 다른 점이 있습니다. 어차피 저 임시 객체는 죽을 운명이라는 거예요! 초기화가 끝나면 임시 객체는 더 이상 자기 데이터를 필요로 하지 않아요. 굳이 똑같은 데이터 두 세트가 동시에 존재할 필요가 없죠. 이런 상황에서 굳이 무겁게 복사를 한 다음에 원본 데이터를 버리는 것은, 너무 비효율적이고 안타까운 일입니다.
그렇다면 복사하는 대신에, arr2 가 임시 객체의 데이터를 살짝 "훔쳐오는(steal)" 방법이 있다면 어떨까요?
그러면 arr2 가 그 데이터의 새로운 주인이 되고, 번거롭게 데이터를 복사할 필요도 없어집니다. 이렇게 데이터의 소유권이 한 객체에서 다른 객체로 넘어가는 것을 가리켜 데이터가 이동(moved)되었다 고 표현합니다. 이렇게 이동하는 비용은 정말 깃털처럼 가볍습니다 (보통 메모리 주소 포인터 몇 개만 쓱 바꿔치기하면 되니까, 거대한 배열을 통째로 복사하는 것보다 엄청나게 빠르죠!).
덤으로 얻는 장점도 있어요! 코드가 끝나고 임시 객체가 파괴될 때, 이미 데이터를 다 뺏기고 텅텅 비어있기 때문에 파괴할 데이터조차 남아있지 않습니다. 데이터를 없애는 데 드는 수고마저 덜게 되는 거죠.
이것이 바로 이동 의미론(Move semantics) 의 핵심입니다! 한 객체에서 다른 객체로 데이터를 어떻게 넘겨줄지 정해주는 규칙이죠. 이 마법 같은 규칙이 발동되면, 이동할 수 있는 데이터는 모조리 이동시키고, 이동이 불가능한 데이터만 복사합니다. 비싸고 느린 복사를 싸고 빠른 이동으로 대체할 수 있기 때문에 훨씬 더 효율적입니다.
핵심 포인트
이동 의미론은 특정 상황에서 데이터를 비싸게 복사하는 대신, 데이터의 소유권을 다른 객체로 아주 저렴하게 넘겨주는 훌륭한 최적화 기술입니다.
(이동할 수 없는 데이터라면 어쩔 수 없이 복사하게 됩니다.)
보통은 같은 타입의 객체로 초기화하거나 값을 넣을 때, 복사 규칙이 사용됩니다.
(복사 생략이 일어나지 않았다고 가정했을 때요)
참고
복사 생략(copy elision)에 대한 내용은 '14.15 강의 - 클래스 초기화와 복사 생략'에서 다루었습니다.
하지만, 다음 세 가지 조건이 모두 만족될 때 는 복사 대신 '이동'이 발동됩니다!
여기서 슬픈 소식이 하나 있습니다. 아직 이 기능을 지원하는 타입이 그리 많지는 않다는 거예요. 하지만 다행히도 우리가 자주 쓰는 std::vector 와 std::string 은 둘 다 완벽하게 지원한답니다!
이동 규칙이 정확히 어떻게 굴러가는지에 대한 깊은 내용은 22장에서 파헤쳐 볼 예정입니다. 지금은 그저 "이동 의미론이 대체 뭔지", 그리고 "어떤 녀석들이 이동을 할 수 있는지" 정도만 알아두셔도 충분해요.
함수에서 '값으로 반환'하게 되면 그 결과물은 1회용 임시 객체(rvalue)가 됩니다.
따라서 반환되는 타입이 이동 의미론을 지원하기만 한다면, 목적지 객체에 데이터를 복사하는 대신 휙! 하고 이동 시킬 수 있어요. 덕분에 이런 타입들을 값으로 반환하는 건 비용이 거의 안 드는 아주 가벼운 작업이 됩니다!
핵심 포인트
우리는std::vector나std::string처럼 이동 가능한 타입 을 부담 없이 '값으로 반환'할 수 있습니다. 무겁게 복사하지 않고 가볍게 값을 휙 던져주니까요!
(단, 이런 타입들을 함수에 인자로 넘겨줄 때는 여전히 'const 참조' 방식을 사용해야 합니다.)
"잠깐, 잠깐만요! 복사하기 무거운 애들은 '값으로 넘기기' 하면 안 된다면서요?
근데 이동이 가능하면 '값으로 반환'하는 건 괜찮다고요?"
네, 완벽하게 정답입니다!
아래부터 이어지는 내용은 안 보셔도 무방하지만, 왜 이런 마법이 가능한지 이해하는 데 큰 도움이 될 거예요.
C++ 프로그래밍을 하면서 우리가 가장 많이 하는 행동 중 하나는, 함수에 어떤 값을 던져주고 다른 값을 받아오는 겁니다. 만약 주고받는 값이 클래스 타입이라면, 이 과정은 크게 4단계 로 나뉩니다.
std::vector 를 이용한 예제로 이 과정을 살펴볼까요?
#include <iostream>
#include <vector>
std::vector<int> doSomething(std::vector<int> v2)
{
std::vector v3 { v2[0] + v2[0] }; // 3단계: 호출한 곳으로 돌려줄 결과값을 만듭니다.
return v3; // 4단계: 실제로 값을 돌려줍니다(반환).
}
int main()
{
std::vector v1 { 5 }; // 1단계: 함수에 던져줄 값을 만듭니다.
std::cout << doSomething(v1)[0] << '\n'; // 2단계: 실제로 값을 함수 안으로 넘겨줍니다.
std::cout << v1[0] << '\n';
return 0;
}
먼저 std::vector 가 '이동 능력'이 전혀 없다고 가정해 봅시다.
이 경우 위 프로그램은 무려 네 번의 복사 를 하게 됩니다.
v1 에 복사.v1 을 매개변수 v2 에 복사.v3 에 복사.v3 를 바깥으로 복사.자, 이제 이걸 어떻게 최적화할 수 있을지 이야기해 볼까요? 우리에겐 '참조'로 넘기기, '복사 생략(elision)', '이동 의미론', '출력 매개변수' 같은 다양한 무기들이 있습니다.
1번과 3번 복사는 최적화할 방법이 아예 없습니다. 함수에 넘겨줄 벡터가 필요하고, 반환할 벡터도 필요하기 때문에 무조건 뼈대가 되는 객체를 생성해야만 하거든요.
우리가 건드릴 수 있는 부분은 바로 2번과 4번 복사 입니다.
2번 복사 최적화하기
2번 복사 는 우리가 함수를 부를 때 '값으로 넘기기'를 했기 때문에 발생했습니다.
대안이 있을까요?
- 참조나 주소로 넘길 수 있나요?
네! 넘겨준 객체(v1)가 갑자기 사라지지 않는다는 게 보장되니까요.
- 이 복사를 생략할 수 있나요?
아니요. 쓸데없는 중복 복사가 아니기 때문에 불가능합니다.
- 출력 매개변수를 쓸 수 있나요?
아니요. 우린 지금 값을 넣는 중이지 받아오는 게 아니에요.
- 이동 의미론을 쓸 수 있나요?
아니요! 인자로 넘긴v1은 나중에도 써야 하는 멀쩡한 변수입니다. 만약 데이터를v2로 이동시켜 뺏어버리면,v1은 텅 비어버리고 나중에v1[0]을 출력하려다 프로그램이 터질 수 있습니다.결론적으로 여기서는 'const 참조'로 넘기는 게 최고의 선택 입니다. 무거운 복사를 피할 수 있고 제일 안전하니까요!
4번 복사 최적화하기
4번 복사 는 함수 안에서 다 만든 값을 바깥으로 반환할 때 발생합니다.
- 참조나 주소로 반환할 수 있나요?
절대 안 됩니다!v3는 함수 안에서 만들어져서 함수가 끝나면 파괴될 녀석입니다. 파괴될 녀석의 주소를 돌려주면 큰일 납니다.
- 이 복사를 생략할 수 있나요?
네, 가능성이 높습니다! 컴파일러가 똑똑하다면 코드를 요령껏 재배치해서 애초에 복사를 안 만들 수 있습니다. 하지만 컴파일러 마음이라 100% 보장할 수는 없어요.
- 출력 매개변수를 쓸 수 있나요?
네. 하지만 코드가 복잡하고 못생겨지며 제약이 너무 많아서 좋은 방법은 아닙니다.
- 이동 의미론을 쓸 수 있나요?
네! 어차피v3는 함수가 끝나면 파괴될 운명입니다. 무겁게 밖으로 복사할 필요 없이, 이동 의미론을 써서 v3의 데이터를 밖으로 휙 던져주고(이동) 빠지면 완벽합니다!
가장 좋은 시나리오는 컴파일러가 알아서 복사를 생략해 주는 거지만, 이건 우리가 통제할 수 없습니다. 그 다음으로 훌륭한 최고의 대안이 바로 이동 의미론입니다!
컴파일러가 복사를 안 없애줄 때 구세주가 되어주거든요. 게다가 이동할 수 있는 타입들은 우리가 '값으로 반환'하기만 하면 알아서 자동으로 발동합니다!
정리하자면...
std::vector처럼 이동이 가능한 타입들을 다룰 때는
'함수 인자로 넣을 땐 const 참조로, 밖으로 뺄 땐 값으로 반환'
하는 것이 최고의 비법입니다.
이 챕터의 첫 번째 강의(16.1 - 컨테이너와 배열 소개)에서는 비슷한 역할을 하는 변수들이 엄청나게 많아질 때 생기는 골치 아픈 문제들을 이야기했었죠. 이번 강의에서는 그 문제를 다시 한번 짚어보고, 배열 이 어떻게 이 문제들을 마법처럼 깔끔하게 해결해 주는지 알아볼 거예요!
학생 5명으로 이루어진 반의 시험 점수 평균을 구한다고 해볼까요?
(설명을 간단하게 하기 위해 학생 수를 5명으로 줄였어요.)
각각의 변수를 따로따로 만들어서 코드를 짜면 이렇게 될 겁니다.
#include <iostream>
int main(){
// 5개의 정수 변수를 만듭니다 (각각 이름이 다 달라요!)
int testScore1{ 84 };
int testScore2{ 92 };
int testScore3{ 76 };
int testScore4{ 81 };
int testScore5{ 56 };
int average { (testScore1 + testScore2 + testScore3 + testScore4 + testScore5) / 5 };
std::cout << "The class average is: " << average << '\n';
return 0;
}
변수도 너무 많고, 직접 타이핑해야 할 것도 참 많죠?
만약 학생이 30명, 아니 600명이라면 어떨지 상상해 보세요.
게다가 새로운 시험 점수를 하나 추가하려면 변수를 새로 만들고, 값을 넣어주고, 평균 계산식에도 일일이 더해줘야 해요. 평균을 구할 때 나누는 숫자(위 코드에서는 5)를 바꾸는 것도 잊지 않으셨겠죠? 깜빡했다면 계산 결과가 틀려지는 오류가 발생할 거예요. 기존 코드를 손봐야 할 때마다 이렇게 실수할 위험이 커진답니다.
이쯤 되면 관련된 변수가 무더기로 있을 때는 배열 을 써야 한다는 걸 아실 거예요. 자, 개별 변수들을 std::vector 로 싹 바꿔볼게요!
#include <iostream>
#include <vector>
int main(){
std::vector testScore { 84, 92, 76, 81, 56 };
std::size_t length { testScore.size() };
int average { (testScore[0] + testScore[1] + testScore[2] + testScore[3] + testScore[4])
/ static_cast<int>(length) };
std::cout << "The class average is: " << average << '\n';
return 0;
}
오, 훨씬 낫네요! 만들어야 하는 변수 개수가 확 줄었고, 평균을 구할 때 나누는 값도 직접 숫자를 적는 대신 배열의 길이(length)에서 바로 가져오고 있어요.
하지만 평균을 구하는 계산식은 아직도 좀 찜찜합니다. 배열 안에 있는 요소(데이터)들을 [0], [1] 처럼 일일이 손으로 다 적어줘야 하니까요. 이렇게 직접 적어주면, 우리가 적은 개수와 똑같은 길이의 배열에서만 이 계산식이 작동해요. 만약 배열 길이가 달라지면, 그때마다 그 길이에 맞는 새로운 계산식을 또 만들어야 하죠.
우리에게 진짜 필요한 건, 요소들을 하나하나 적는 노가다 없이
배열의 모든 요소에 쉽게 접근할 수 있는 방법 이에요.
이전 강의에서 배열의 인덱스(순서를 나타내는 번호)는 꼭 고정된 숫자일 필요가 없다고 배웠어요. 즉, 프로그램이 실행 중일 때 값이 변하는 '변수'를 인덱스로 쓸 수 있다는 뜻이에요!
위의 평균 계산식에서 쓴 인덱스들을 보면 0, 1, 2, 3, 4 처럼 차례대로 1씩 커지고 있죠. 그렇다면 어떤 변수의 값을 0부터 4까지 차례대로 변하게 만들 수 있다면, 일일이 숫자를 쓰는 대신 그 변수를 인덱스 자리에 쏙 넣으면 되지 않을까요? 우리는 이미 그 방법을 알고 있어요. 바로 for 반복문 을 쓰는 거예요!
이제 for 반복문을 써서 위의 예제를 다시 써볼게요.
반복문에서 1씩 커지는 변수를 배열의 인덱스로 쓸 겁니다.
#include <iostream>
#include <vector>
int main(){
std::vector testScore { 84, 92, 76, 81, 56 };
std::size_t length { testScore.size() };
int average { 0 };
for (std::size_t index{ 0 }; index < length; ++index) // index를 0부터 '길이-1'까지 반복시킵니다
average += testScore[index]; // 해당 `index` 번호에 있는 값을 average에 더합니다
average /= static_cast<int>(length); // 다 더한 값을 길이로 나누어 평균을 계산합니다
std::cout << "The class average is: " << average << '\n';
return 0;
}
작동 원리는 아주 단순해요. index 변수가 0으로 시작해서 testScore[0] 의 값이 average 에 더해집니다. 그러고 나면 index 가 1로 늘어나죠. 그다음 testScore[1] 이 더해지고, 다시 index 가 2로 늘어납니다. 이렇게 계속되다가 마침내 index 가 5가 되면, index < length (5 < 5)라는 조건이 거짓(false)이 되면서 반복문이 알아서 종료됩니다.
이 시점이 되면 반복문 덕분에 testScore[0] 부터 testScore[4] 까지의 값이 모두 average 에 알차게 더해져 있을 거예요.
마지막으로, 이렇게 싹 다 더한 값을 배열의 길이로 나눠서 진짜 평균을 구하는 거죠.
이 방법은 코드를 관리하기에 정말 완벽해요! 반복문이 도는 횟수는 배열의 길이에 따라 자동으로 정해지고, 반복문 변수가 배열의 인덱스 역할을 알아서 척척 해줍니다. 이제 배열 요소를 일일이 손으로 적을 필요가 없어요.
만약 시험 점수를 추가하거나 빼고 싶다면? 처음에 배열을 만들 때 숫자만 추가/삭제하면 끝이에요. 나머지 코드는 단 한 줄도 고칠 필요 없이 완벽하게 작동한답니다!
이렇게 컨테이너(배열 같은 자료 구조)의 각 요소를 하나씩 순서대로 확인하는 것을 프로그래밍 용어로 순회(traversal) 라고 해요. 다른 말로는 반복(iteration) 한다거나 이터레이팅(iterating) 한다고도 부릅니다. 꼭 기억해 두세요!
작가의 노트
컨테이너 클래스들은 길이나 인덱스를 다룰 때 보통size_t라는 타입을 사용해요. 그래서 이번 강의에서도 똑같이size_t를 썼습니다. 음수까지 표현할 수 있는(signed) 타입들을 사용하는 방법은 다가오는 16.7 강의에서 다룰게요.
이 세 가지가 합쳐지면 정말 놀라운 일이 벌어집니다! 안에 들어있는 데이터가 정수든 소수든, 개수가 5개든 100개든 상관없이 어떤 컨테이너라도 완벽하게 다룰 수 있는 코드를 짤 수 있거든요.
이해를 돕기 위해, 방금 만든 평균 계산 코드를 '함수 템플릿'이라는 형태로 업그레이드해 볼게요.
#include <iostream>
#include <vector>
// std::vector 안에 있는 값들의 평균을 계산해 주는 함수 템플릿입니다
template <typename T>
T calculateAverage(const std::vector<T>& arr){
std::size_t length { arr.size() };
T average { 0 }; // 배열 안에 든 요소가 T 타입이면, 평균값을 저장할 변수도 T 타입이어야겠죠?
for (std::size_t index{ 0 }; index < length; ++index) // 모든 요소를 처음부터 끝까지 순회합니다
average += arr[index]; // 모든 요소를 싹 다 더합니다
average /= static_cast<int>(length); // 전체 개수로 나눕니다 (개수는 사람 수나 사물 개수처럼 자연스러운 정수니까요)
return average;
}
int main(){
std::vector class1 { 84, 92, 76, 81, 56 };
std::cout << "The class 1 average is: " << calculateAverage(class1) << '\n'; // 정수형(int) 5개의 평균을 계산합니다!
std::vector class2 { 93.2, 88.6, 64.2, 81.0 };
std::cout << "The class 2 average is: " << calculateAverage(class2) << '\n'; // 소수형(double) 4개의 평균을 계산합니다!
return 0;
}
이 코드를 실행하면 이렇게 출력됩니다.
The class 1 average is: 77
The class 2 average is: 81.75
위의 예제에서 우리는 calculateAverage() 라는 똑똑한 함수 템플릿을 만들었어요. 이 함수는 안에 든 데이터 종류나 길이에 상관없이 어떤 std::vector 를 던져줘도 평균을 구해냅니다. main() 함수를 보면 정수(int) 5개가 든 배열이든, 소수점(double) 4개가 든 배열이든 똑같이 잘 작동하는 걸 확인할 수 있어요!
calculateAverage() 함수는 안에서 사용된 더하기(+=)나 나누기(/=) 같은 연산이 가능한 타입(T)이라면 뭐든지 다 처리할 수 있습니다. 만약 더하기나 나누기를 할 수 없는 이상한 타입을 넣으면? 컴파일러가 알아채고 당장 오류를 뿜어낼 거예요.
참, 코드를 보면서 왜 길이를 나타내는 length 를 굳이 int 로 바꿨는지(static_cast<int>) 궁금하실 수도 있어요. 평균을 구할 때는 다 더한 총합을 항목의 '개수'로 나누죠. 개수라는 건 소수점이 없는 정수(integral value)잖아요? 그래서 코드를 읽을 때 "아, 의미상 정수로 나누는 게 맞지" 하고 바로 이해할 수 있도록 int 로 명확하게 바꿔준 거랍니다.
이제 반복문으로 컨테이너를 훑어보는(순회하는) 방법을 완벽히 알았으니, 실전에서 주로 이걸로 뭘 하는지 살펴볼게요. 우리는 보통 다음 4가지 중 하나를 하려고 반복문을 씁니다.
첫 번째부터 세 번째까지는 꽤 쉬워요. 반복문을 한 바퀴만 돌리면서 데이터를 슬쩍 보거나 입맛대로 바꾸면 되니까요.
하지만 요소들의 순서를 바꾸는(정렬) 작업은 꽤 까다롭습니다. 보통 반복문 안에 반복문을 또 넣어야 하거든요. 우리가 직접 머리를 싸매고 코드를 짤 수도 있지만, 사실 C++ 표준 라이브러리에 아주 훌륭한 알고리즘들이 이미 다 만들어져 있어요! 그냥 그걸 가져다 쓰는 게 훨씬 똑똑한 방법이죠. 이 부분은 나중에 알고리즘 챕터에서 더 자세히 알려드릴게요.
인덱스를 써서 배열을 훑어볼 때는 반복문이 정확한 횟수만큼 도는지 눈에 불을 켜고 확인해야 해요. 초보자뿐만 아니라 전문가도 가장 많이 하는 실수가 바로 '1 차이 오류'랍니다. 반복문이 실수로 한 번 더 돌거나 한 번 덜 도는 거죠.
보통은 index 를 0 부터 시작해서 index < length 일 때까지만 반복하도록 코드를 짭니다.
하지만 프로그래밍을 처음 하시는 분들은 실수로 조건에 작거나 같다(<=) 를 써서 index <= length 처럼 만드는 경우가 종종 있어요. 이렇게 되면 index 와 length 가 같아질 때도 반복문이 돌아가 버려요. 결국 배열의 크기를 넘어서 엉뚱한 메모리를 건드리게 되고, 프로그램이 알 수 없는 이상한 행동을 하게 만드는 무서운 결과를 낳습니다. 조심, 또 조심하세요!
이전 레슨인 '4.5 - 부호 없는 정수와 이를 피해야 하는 이유'에서, 우리는 수량을 저장할 때 일반적으로 부호 있는 값을 선호한다고 배웠습니다. 부호 없는 값은 가끔 우리의 예상과 전혀 다르게 동작할 수 있기 때문이죠. 하지만 '16.3 - std::vector 와 부호 없는 길이 및 인덱스 문제' 레슨에서 다루었듯이, std::vector 와 같은 컨테이너 클래스들은 길이와 인덱스를 표현할 때 부호 없는 정수 타입인 std::size_t 를 사용합니다.
이로 인해 다음과 같은 골치 아픈 문제가 발생할 수 있습니다.
#include <iostream>
#include <vector>
template <typename T>
void printReverse(const std::vector<T>& arr)
{
for (std::size_t index{ arr.size() - 1 }; index >= 0; --index) // index는 부호 없는(unsigned) 정수입니다
{
std::cout << arr[index] << ' ';
}
std::cout << '\n';
}
int main()
{
std::vector arr{ 4, 6, 7, 3, 8, 2, 1, 9 };
printReverse(arr);
return 0;
}
이 코드를 실행하면 처음에는 배열을 거꾸로 잘 출력하는 것처럼 보입니다.
9 1 2 8 3 7 6 4
하지만 그 직후, 정의되지 않은 동작을 일으킵니다.
쓰레기 값을 출력하거나 프로그램이 아예 튕겨버릴 수 있죠.
여기에는 두 가지 큰 문제가 숨어 있습니다.
첫째, 우리의 루프는 index >= 0 (즉, 인덱스가 양수인 동안) 계속 실행되도록 작성되었습니다. 그런데 index가 부호 없는(unsigned) 타입이라면, 이 조건은 항상 참(true) 이 됩니다. 마이너스 값이 아예 존재할 수 없으니까요! 결과적으로 이 루프는 영원히 끝나지 않습니다.
둘째, index가 0일 때 1을 빼면(감소시키면), 마이너스 값이 되는 대신 엄청나게 큰 양수 로 한 바퀴 돌아가 버립니다(이를 랩어라운드, Wrap-around라고 합니다).
그리고 다음 루프에서 이 거대한 숫자를 배열의 인덱스로 사용하게 되죠. 이는 배열의 진짜 크기를 완전히 벗어난 접근(Out-of-bounds)이며, 프로그램을 망가뜨리는 원인이 됩니다. 만약 배열(벡터)이 텅 비어있을 때도 똑같은 문제가 발생합니다.
이런 문제를 피하는 여러 가지 꼼수가 있긴 하지만, 애초에 이런 방식 자체가 버그를 끌어당기는 자석과도 같습니다.
루프 변수로 부호 있는(signed) 타입을 사용하면 이런 문제를 훨씬 쉽게 피할 수 있지만, 대신 또 다른 귀찮은 점이 생깁니다. 다음은 부호 있는 인덱스를 사용해서 위 문제를 고쳐본 코드입니다.
#include <iostream>
#include <vector>
template <typename T>
void printReverse(const std::vector<T>& arr)
{
for (int index{ static_cast<int>(arr.size()) - 1}; index >= 0; --index) // index는 부호 있는(signed) 정수입니다
{
std::cout << arr[static_cast<std::size_t>(index)] << ' ';
}
std::cout << '\n';
}
int main()
{
std::vector arr{ 4, 6, 7, 3, 8, 2, 1, 9 };
printReverse(arr);
return 0;
}
이 코드는 우리가 원하는 대로 완벽하게 작동합니다. 하지만 코드가 굉장히 지저분해졌습니다. static_cast (타입 변환)가 두 번이나 들어갔기 때문이죠.
특히 arr[static_cast<std::size_t>(index)] 부분은 눈으로 읽기 너무 불편합니다.
안전성을 얻은 대신, 코드를 읽기 쉽게 만드는 가독성을 크게 희생한 셈입니다.
부호 있는 인덱스를 사용한 또 다른 예시를 볼까요?
#include <iostream>
#include <vector>
// std::vector의 평균값을 계산하는 함수 템플릿
template <typename T>
T calculateAverage(const std::vector<T>& arr)
{
int length{ static_cast<int>(arr.size()) };
T average{ 0 };
for (int index{ 0 }; index < length; ++index)
average += arr[static_cast<std::size_t>(index)];
average /= length;
return average;
}
int main()
{
std::vector testScore1 { 84, 92, 76, 81, 56 };
std::cout << "The class 1 average is: " << calculateAverage(testScore1) << '\n';
return 0;
}
여기서도 static_cast 때문에 코드가 지저분해지는 건 여전합니다. 정말 끔찍하죠.
그럼 도대체 어떻게 해야 할까요? 안타깝게도 이 문제에 대해 완벽한 정답은 없습니다.
대신 우리가 쓸 수 있는 여러 가지 선택지들이 있습니다. 지금부터 저희가 생각하기에 가장 최악인 방법부터 가장 추천하는 방법 순서 로 소개해 드리겠습니다. 실무에서 다른 사람들이 작성한 코드를 보면 이 방법들을 모두 마주치게 될 것입니다.
저자의 참고 사항
여기서는 std::vector 를 기준으로 설명하지만, std::array 와 같은 모든 표준 라이브러리 컨테이너들도 똑같이 작동하며 같은 문제를 가지고 있습니다. 따라서 아래의 설명은 어떤 컨테이너를 쓰든 똑같이 적용됩니다.
혹시 컴파일러에서 부호 변환 경고가 왜 기본적으로 꺼져 있는 경우가 많은지 궁금하셨다면, 바로 이 문제 때문입니다. 표준 라이브러리 컨테이너에 부호 있는 인덱스를 사용할 때마다 경고가 발생하거든요. 이 경고들이 컴파일 로그를 꽉 채워버리면, 정작 우리가 고쳐야 할 진짜 중요한 경고들을 놓치게 됩니다.
그래서 아예 이 경고들을 무시하도록 설정하는 것이 하나의 방법입니다.
가장 간단한 해결책이지만, 절대 추천하지 않습니다. 자칫하면 진짜 버그를 일으킬 수 있는 위험한 부호 변환 문제까지 모두 숨겨버리기 때문입니다.
많은 개발자들이 "표준 라이브러리가 원래 부호 없는 인덱스를 쓰도록 만들어졌으니, 우리도 부호 없는 인덱스를 쓰면 되지!" 라고 생각합니다. 아주 타당한 의견입니다. 단, 부호가 있는 값과 없는 값이 섞여서 발생하는 문제(mismatch)가 생기지 않도록 각별히 조심해야 합니다. 가능하면 인덱스 루프 변수는 순수하게 '위치를 찾는 용도'로만 사용하는 것이 좋습니다.
그렇다면 어떤 부호 없는 타입을 사용해야 할까요?
이전 레슨에서 표준 라이브러리 컨테이너들은 size_type 이라는 내부 타입을 가지고 있다고 배웠습니다. .size() 함수도 이 타입을 반환하고, [] 기호도 이 타입을 인덱스로 사용하죠. 따라서 기술적으로는 size_type 을 사용하는 것이 가장 안전하고 일관된 방법입니다.
#include <iostream>
#include <vector>
int main()
{
std::vector arr { 1, 2, 3, 4, 5 };
for (std::vector<int>::size_type index { 0 }; index < arr.size(); ++index)
std::cout << arr[index] << ' ';
return 0;
}
하지만 이 방법엔 아주 큰 단점이 있습니다. 이름이 너무 길다는 것이죠! std::size_type 이라고 간단히 쓸 수 없고, std::vector<int>::size_type 처럼 컨테이너의 전체 이름을 다 적어줘야 합니다. 타이핑하기도 힘들고 읽기도 불편합니다.
심지어 템플릿 함수 안에서 사용할 때는 앞에 typename 이라는 키워드까지 붙여줘야 합니다.
#include <iostream>
#include <vector>
template <typename T>
void printArray(const std::vector<T>& arr)
{
// 의존적 이름(dependent type)에는 typename 키워드 접두사가 필요합니다
for (typename std::vector<T>::size_type index { 0 }; index < arr.size(); ++index)
std::cout << arr[index] << ' ';
}
int main()
{
std::vector arr { 9, 7, 5, 3, 1 };
printArray(arr);
return 0;
}
이 코드를 더 짧게 줄이기 위해 decltype 이나 별칭(using)을 쓸 수도 있지만, 여전히 기억하기 어렵고 복잡합니다.
그래서 많은 프로그래머들은 길고 복잡한 size_type 대신, 기억하기도 쉽고 치기도 편한 std::size_t 를 직접 사용합니다. (사실 size_type 도 결국 대부분 size_t 와 똑같기 때문입니다.)
for (std::size_t index { 0 }; index < arr.size(); ++index)
여러분이 직접 메모리 할당기를 만드는 고수급 개발자가 아니라면, 이 정도면 꽤 합리적인 방법입니다.
표준 라이브러리를 다루기엔 조금 번거로워지지만, 루프 변수로 부호 있는 값을 사용하는 것은 우리가 평소에 쓰는 코드 스타일(수량엔 부호 있는 값을 쓴다)과 일치합니다.
좋은 습관을 일관되게 지킬수록 전체적인 에러는 줄어들기 마련이죠.
부호 있는 변수를 쓰려면 세 가지 문제를 해결해야 합니다.
1. 어떤 부호 있는 타입을 사용해야 할까요?
엄청나게 큰 배열을 다루는 게 아니라면, 그냥 우리가 평소에 쓰는 기본 숫자인 int 를 쓰시면 됩니다.
만약 배열이 아주 크거나 좀 더 안전하게 코딩하고 싶다면, 이름은 좀 이상하지만 std::ptrdiff_t 라는 타입을 사용할 수 있습니다. 이는 std::size_t 의 부호 있는 버전이라고 생각하시면 됩니다.
이름이 너무 어려우니, 아래처럼 직접 별명을 만들어(alias) 사용하는 것도 좋은 팁입니다.
using Index = std::ptrdiff_t;
// index를 사용한 샘플 루프
for (Index index{ 0 }; index < static_cast<Index>(arr.size()); ++index)
2. 배열의 길이를 부호 있는 값으로 가져오기
C++20 이전 버전에서는 배열의 크기를 반환하는 .size() 의 결과값에 static_cast 를 씌워 부호 있는 타입으로 바꿔주는 것이 최선이었습니다.
하지만 C++20 부터는 구세주가 등장했습니다! 바로 std::ssize() 입니다. 이 함수는 알아서 배열의 크기를 '부호 있는 타입'으로 반환해 줍니다. C++ 언어를 만드는 사람들도 이제는 "부호 있는 인덱스를 쓰는 게 맞다"고 생각한다는 강력한 증거이기도 하죠.
#include <iostream>
#include <vector>
int main()
{
std::vector arr{ 9, 7, 5, 3, 1 };
for (auto index{ std::ssize(arr)-1 }; index >= 0; --index) // std::ssize는 C++20에 도입되었습니다
std::cout << arr[static_cast<std::size_t>(index)] << ' ';
return 0;
}
3. 부호 있는 루프 변수를 부호 없는 인덱스로 변환하기
부호 있는 변수를 만들었지만, 막상 배열 안에 arr[index] 처럼 넣으려고 하면 또 경고가 뜹니다. 그래서 매번 static_cast 를 써야 하는데 코드가 지저분해지죠.
이를 해결하기 위해 짧은 이름의 도우미 함수를 직접 만들 수도 있습니다.
#include <iostream>
#include <type_traits> // std::is_integral과 std::is_enum을 위해 필요
#include <vector>
using Index = std::ptrdiff_t;
// value를 std::size_t 타입의 객체로 변환하는 도우미 함수
// UZ는 std::size_t 타입 리터럴의 접미사입니다.
template <typename T>
constexpr std::size_t toUZ(T value)
{
// T가 정수형 타입인지 확인합니다
static_assert(std::is_integral<T>() || std::is_enum<T>());
return static_cast<std::size_t>(value);
}
int main()
{
std::vector arr{ 9, 7, 5, 3, 1 };
auto length { static_cast<Index>(arr.size()) }; // C++20에서는 std::ssize() 사용을 권장합니다
for (auto index{ length-1 }; index >= 0; --index)
std::cout << arr[toUZ(index)] << ' '; // 부호 변환 경고를 피하기 위해 toUZ()를 사용합니다
return 0;
}
arr[toUZ(index)] 라고 쓰니 아까보다 훨씬 읽기 편해졌죠?
이전 레슨에서, 컨테이너를 직접 인덱싱하는 대신 .data() 함수를 써서 그 안에 숨겨진 진짜 C 스타일 배열을 꺼내 쓸 수 있다고 배웠습니다. C 스타일 배열은 부호가 있든 없든 가리지 않고 인덱스를 받아주기 때문에, 복잡한 변환 경고 문제를 완벽하게 피해갈 수 있습니다!
int main()
{
std::vector arr{ 9, 7, 5, 3, 1 };
auto length { static_cast<Index>(arr.size()) }; // C++20에서는 std::ssize() 사용을 권장합니다
for (auto index{ length - 1 }; index >= 0; --index)
std::cout << arr.data()[index] << ' '; // 부호 변환 경고를 피하기 위해 data()를 사용합니다
return 0;
}
저희는 인덱스를 꼭 써야 한다면 이 방법이 가장 좋다고 생각합니다:
.data() 를 붙인다고 해서 코드가 크게 지저분해지지 않습니다.지금까지 여러 가지 꼼수와 방법들을 소개해 드렸지만, 사실 모두 어느 정도의 단점을 가지고 있습니다. 하지만 이 모든 스트레스에서 벗어날 수 있는 훨씬 깔끔하고 현명한 방법이 있습니다. 바로 인덱스 숫자 자체를 쓰지 않는 것입니다.
C++ 에는 인덱스 숫자 없이도 배열 안의 데이터들을 하나씩 꺼내볼 수 있는 훌륭한 기능들이 있습니다. 숫자를 쓰지 않으니 부호 문제로 골치 아플 일도 없죠!
가장 대표적인 두 가지 방법이 바로 범위 기반 for 루프(range-based for loops) 와 반복자(iterators) 입니다.
관련 내용
범위 기반 for 루프는 다음 레슨 (16.8)에서 배웁니다.
반복자(Iterators)는 다가오는 레슨 (18.2)에서 배웁니다.
만약 여러분이 인덱스 변수를 단지 '배열의 처음부터 끝까지 돌아보기 위한 용도'로만 쓰고 계셨다면, 무조건 인덱스를 쓰지 않는 방법을 우선으로 선택하세요.
모범 사례
가능한 한 정수 숫자를 이용한 배열 인덱싱은 피하세요.
이전 16.6 레슨(배열과 반복문)에서는 인덱스(순서 번호)를 사용해서 배열의 각 항목을 하나씩 확인하는 기본 for 문을 배웠습니다. 예시를 다시 한번 볼까요?
#include <iostream>
#include <vector>
int main()
{
std::vector fibonacci { 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 };
std::size_t length { fibonacci.size() };
for (std::size_t index { 0 }; index < length; ++index)
std::cout << fibonacci[index] << ' ';
std::cout << '\n';
return 0;
}
기본 for 문은 배열을 순서대로 살펴볼 때 아주 편리한 방법입니다. 하지만 배열의 길이를 잘못 계산하거나, 처음과 끝을 잘못 지정하는 등(off-by-one 에러) 실수하기가 아주 쉽다는 단점이 있습니다. 게다가 인덱스의 부호 문제로 골치를 앓을 수도 있죠.
배열을 처음부터 끝까지 쭉 훑어보는 작업은 프로그래밍에서 정말 자주 쓰입니다. 그래서 C++은 인덱스를 헷갈리게 계산하지 않아도 되는 아주 편리한 기능을 제공합니다. 바로 범위 기반 for 문 (또는 for-each 문 이라고도 부름)입니다! 이 반복문은 사용하기 훨씬 간단하고 안전하며, std::vector, std::array 등 C++에서 쓰는 거의 모든 배열 타입에 사용할 수 있습니다.
범위 기반 for 문 은 다음과 같은 형태로 작성합니다:
for (element_declaration : array_object)
statement;
컴퓨터가 이 반복문을 만나면, array_object(배열) 안에 있는 항목들을 처음부터 끝까지 하나씩 차례대로 꺼내옵니다. 반복될 때마다 현재 항목의 값이 element_declaration
(새로 만든 변수)에 쏙 들어가고, 그 밑에 있는 statement(실행할 코드)가 작동하는 방식입니다.
여기서 팁! 새로 선언하는 변수의 타입은 배열 안에 들어있는 항목의 타입과 똑같아야 합니다. 다르면 컴퓨터가 억지로 타입을 바꾸려다가 문제가 생길 수 있어요.
피보나치(fibonacci) 배열의 모든 항목을 출력하는 아주 간단한 예시를 볼까요?
#include <iostream>
#include <vector>
int main()
{
std::vector fibonacci { 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 };
for (int num : fibonacci) // fibonacci 배열을 순회하며 각 값을 `num`에 복사합니다.
std::cout << num << ' '; // `num`의 현재 값을 출력합니다.
std::cout << '\n';
return 0;
}
이 코드를 실행하면 다음과 같이 나옵니다:
0 1 1 2 3 5 8 13 21 34 55 89
정말 놀랍지 않나요? 배열의 전체 길이가 얼마인지 직접 적을 필요도 없고, 헷갈리는 인덱스([i])를 쓸 필요도 없습니다!
작동 원리를 조금 더 자세히 살펴볼까요?
이 반복문은 fibonacci 배열의 모든 항목을 끝까지 훑어봅니다.
num 에 첫 번째 값인 0이 들어갑니다. 그리고 화면에 0을 출력하죠.num 에 두 번째 값인 1이 들어갑니다. 그리고 1을 출력합니다.이런 식으로 배열에 남은 항목이 없을 때까지 알아서 착착 진행됩니다. 모든 항목을 다 보면 반복문은 스스로 조용히 종료되고, 프로그램은 다음 단계로 넘어갑니다.
핵심 포인트
여기서 선언한 변수(num)는 "몇 번째인가요?"를 알려주는 인덱스가 아닙니다.
배열 안에 들어있는 실제 값 그 자체입니다!
변수에 값이 쏙 들어간다는 것은, 원본 배열에 있는 값이 변수로 복사 된다는 뜻이에요. (데이터 덩어리가 크다면 복사하는 데 시간이 꽤 걸리겠죠?)
적극 권장 사항
컨테이너(배열 등) 안의 모든 항목을 훑어볼 때는 복잡한 일반for문보다
범위 기반 for 문 을 사용하는 것이 훨씬 좋습니다.
만약 컨테이너 안에 항목이 하나도 없다면, 범위 기반 for 문 안에 있는 코드는 아예 실행되지 않고 조용히 넘어갑니다.
#include <iostream>
#include <vector>
int main()
{
std::vector empty { };
for (int num : empty)
std::cout << "Hi mom!\n";
return 0;
}
안에 아무것도 없으니 위의 코드는 화면에 아무것도 출력하지 않습니다. 엄마 미안해요!
auto 키워드로 더 똑똑하게 쓰기아까 변수 타입과 배열 항목의 타입을 똑같이 맞춰야 한다고 했죠?
이럴 때 auto 키워드를 쓰면 정말 찰떡궁합입니다. 컴퓨터(컴파일러)가 알아서 배열을 보고
"아, 이 배열에는 정수가 들어있으니 변수도 정수형이겠구나!" 하고 타입을 자동으로 맞춰주거든요. 우리가 일일이 적을 필요도 없고, 오타를 낼 걱정도 사라집니다.
위에서 본 예시에 auto 를 적용해 볼게요:
#include <iostream>
#include <vector>
int main()
{
std::vector fibonacci { 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 };
for (auto num : fibonacci) // 컴파일러가 num의 타입을 `int`로 자동 유추합니다.
std::cout << num << ' ';
std::cout << '\n';
return 0;
}
std::vector fibonacci 에는 int(정수) 타입들이 들어있기 때문에, 컴퓨터는 num 이 int 타입이라는 것을 척 보고 알아냅니다.
적극 권장 사항
범위 기반 for 문을 쓸 때는 auto 키워드를 사용해서 컴퓨터가 스스로 타입을 알아내게 하세요.
auto 를 쓰면 좋은 점이 또 하나 있습니다. 나중에 배열의 데이터 타입이 int에서 더 큰 숫자인 long으로 바뀌더라도, 코드를 수정할 필요가 없습니다. 컴퓨터가 알아서 바뀐 타입에 맞춰주기 때문에 에러가 날 확률이 크게 줄어들죠!
이번에는 글자들(std::string)이 들어있는 배열을 훑어보는 코드를 생각해 봅시다.
#include <iostream>
#include <string>
#include <vector>
int main()
{
std::vector<std::string> words{ "peter", "likes", "frozen", "yogurt" };
for (auto word : words)
std::cout << word << ' ';
std::cout << '\n';
return 0;
}
이 코드가 반복될 때마다 배열에 있는 글자들이 변수 word 로 하나씩 '복사'됩니다. 글자 데이터(std::string)를 복사하는 건 컴퓨터에게 꽤 무겁고 힘든 작업이에요. 단순히 화면에 출력만 하고 버릴 건데, 굳이 무겁게 복사본을 만들 필요는 없겠죠?
다행히 변수를 참조 (reference) 로 만들면 이 문제를 해결할 수 있습니다! 그냥 배열에 있는 원본을 손가락으로 가리키기만 하는 방식이에요.
#include <iostream>
#include <string>
#include <vector>
int main()
{
std::vector<std::string> words{ "peter", "likes", "frozen", "yogurt" };
for (const auto& word : words) // 이제 word는 읽기 전용 참조(const reference)입니다.
std::cout << word << ' ';
std::cout << '\n';
return 0;
}
위 예시에서 word 에 const auto& 를 붙였습니다. 이렇게 하면 매번 반복될 때마다 굳이 무겁게 복사본을 만들지 않고, 원본 데이터에 바로 연결됩니다! 화면에 출력만 할 거라서 const(읽기 전용, 수정 불가)를 붙여서 원본이 다치지 않게 안전장치도 걸어두었죠.
만약 원본 데이터를 직접 수정하고 싶다면 const를 빼고 그냥 auto& 라고 쓰면 됩니다.
auto, auto&, const auto& 를 써야 할까요?가벼운 데이터는 auto , 원본을 수정하고 싶을 때는 auto& , 무거운 데이터는 const auto& 를 쓰는 것이 기본 규칙입니다. 하지만 범위 기반 for 문을 쓸 때는 미래를 대비해서 무조건 const auto& 를 쓰는 것을 추천 하는 전문가들이 많습니다. 왜 그럴까요?
다음 코드를 한번 보세요.
#include <iostream>
#include <string_view>
#include <vector>
int main()
{
std::vector<std::string_view> words{ "peter", "likes", "frozen", "yogurt" }; // 요소들이 std::string_view 타입입니다.
for (auto word : words) // string_view는 보통 값으로 전달하므로, 여기서는 auto를 사용합니다.
std::cout << word << ' ';
std::cout << '\n';
return 0;
}
std::string_view는 아주 가벼운 녀석이라서 복사해도 전혀 부담이 없습니다.
그래서 auto 를 썼죠.
그런데 만약 나중에 프로그램이 수정되어서, 이 배열이 무거운 std::string 배열로 바뀐다면 어떨까요?
#include <iostream>
#include <string>
#include <vector>
int main()
{
std::vector<std::string> words{ "peter", "likes", "frozen", "yogurt" }; // 이 부분을 수정해야 하는 건 명백합니다.
for (auto word : words) // 하지만 이 부분도 수정해야 한다는 건 눈치채기 어렵습니다.
std::cout << word << ' ';
std::cout << '\n';
return 0;
}
이 코드는 아무 에러 없이 잘 실행됩니다. 하지만 auto라고 적어둔 코드 때문에, 갑자기 매번 무거운 문자열 복사 작업이 몰래 일어나게 됩니다. 우리도 모르는 사이에 프로그램이 엄청나게 느려져 버리는 엄청난 손해를 보게 되는 거죠!
이런 무서운 상황을 피하려면 어떻게 해야 할까요?
단순히 복사본을 피하고 싶다면, 처음부터 const auto& 를 기본으로 쓰는 것이 좋습니다. 원본을 살짝 가리키는 방식(참조)은 성능에 나쁜 영향을 거의 주지 않으면서도, 나중에 데이터 타입이 무거운 것으로 바뀌었을 때 갑자기 프로그램이 느려지는 대참사를 완벽하게 막아주기 때문입니다.
권장 사항
범위 기반 for 문에서 요소의 타입을 정할 때는 다음 기준을 따르세요.
- auto : 데이터를 복사해서 그 복사본을 수정하고 싶을 때.
- auto& : 원본 데이터를 직접 수정하고 싶을 때.
- const auto& : 그 외의 모든 경우
(그냥 원본 데이터를 살펴보기만 할 때. 이것이 가장 추천하는 방법입니다!)
범위 기반 for 문은 우리가 배운 기본 배열뿐만 아니라, std::array, std::vector는 물론이고 리스트(Linked list), 트리(Trees), 맵(Maps) 같은 C++의 아주 다양한 데이터 묶음과 모두 찰떡처럼 잘 작동합니다. 아직 이런 것들을 안 배웠어도 전혀 걱정 마세요!
"범위 기반 for 문은 거의 모든 컨테이너에서 쓸 수 있는 만능 도구구나!" 라고만 기억해 두시면 됩니다.
#include <array>
#include <iostream>
int main()
{
std::array fibonacci{ 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 }; // 여기서는 std::array를 사용했다는 점에 주목하세요.
for (auto number : fibonacci)
{
std::cout << number << ' ';
}
std::cout << '\n';
return 0;
}
심화 학습자를 위한 참고
배열의 길이를 잃어버린 '퇴화된(decayed) C-스타일 배열'에서는 이 기능을 쓸 수 없습니다. 범위 기반 for 문은 배열이 언제 끝나는지 전체 길이를 꼭 알아야 하거든요. 또, 열거형(enum)에서도 바로 쓸 수는 없는데, 이를 해결하는 방법은 나중에 17.6 레슨에서 배우게 됩니다.
범위 기반 for 문은 아주 편리하지만, "지금 이게 몇 번째 항목이지?" 하고 알려주는 기능은 쏙 빠져있습니다. C++이 제공하는 여러 컨테이너 중에는 아예 순서 번호(인덱스)라는 개념 자체가 없는 애들도 있기 때문이죠.
이 반복문은 무조건 앞으로만 직진하고 중간에 건너뛰지 않기 때문에, 순서를 세는 변수(카운터)를 따로 하나 만들어서 숫자를 올려가는 방식을 쓸 수는 있습니다. 하지만 굳이 그렇게까지 순서 번호가 필요하다면, 범위 기반 for 문 대신 그냥 옛날 방식의 일반 for 문을 쓰는 게 더 나은 선택일 수 있습니다.
방금 말씀드렸듯, 이 반복문은 무조건 처음부터 끝까지 앞으로만 쭉 직진합니다. 하지만 가끔은 뒤에서부터 거꾸로 읽어오고 싶을 때가 있죠? 예전 C++ 버전에서는 이를 쉽게 할 수 없어서 일반 for 문을 써야만 했습니다.
하지만 최신 C++20 버전부터는 마법 같은 방법이 생겼습니다! Ranges 라이브러리의 std::views::reverse 라는 도구를 쓰면, 거꾸로 훑어보는 뷰(view)를 만들어낼 수 있습니다.
#include <iostream>
#include <ranges> // C++20
#include <string_view>
#include <vector>
int main()
{
std::vector<std::string_view> words{ "Alex", "Bobby", "Chad", "Dave" }; // 알파벳 순서대로 정렬됨
for (const auto& word : std::views::reverse(words)) // 역순 뷰(reverse view)를 생성합니다.
std::cout << word << ' ';
std::cout << '\n';
return 0;
}
이 코드를 실행하면 이름이 거꾸로 출력됩니다:
Dave
Chad
Bobby
Alex
아직 Ranges 라이브러리에 대해 자세히 배우지는 않았으니, 지금은 "최신 버전에는 이런 멋진 마법도 있구나!" 하고 가볍게 알아두시면 충분합니다.
배열을 사용할 때 가장 큰 문제점 중 하나는, 0, 1, 2 같은 숫자 인덱스만으로는 그 칸에 들어있는 데이터가 어떤 의미인지 알 수 없다는 점 입니다.
5명의 시험 점수를 담아둔 배열을 예로 들어볼게요.
#include <vector>
int main()
{
std::vector testScores { 78, 94, 66, 77, 14 };
testScores[2] = 76; // 이 점수는 누구의 것일까요?
}
testScores[2]가 도대체 어느 학생의 점수를 뜻하는 걸까요? 코드만 봐서는 전혀 알 수가 없습니다.
이전 레슨에서 배열(또는 std::vector)의 칸 번호(인덱스)를 지정할 때는 보통 std::size_t라는 양수형 숫자 타입을 써야 한다고 배웠습니다.
그런데 일반적인 enum(범위를 지정하지 않은 열거형)은 컴퓨터가 알아서 std::size_t 타입의 숫자로 척척 변환해 줍니다! 이 원리를 이용하면, 숫자가 아니라 이름표(열거형) 를 배열의 인덱스로 써서 코드의 의미를 아주 명확하게 만들 수 있어요.
#include <vector>
namespace Students
{
enum Names
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
}
int main()
{
std::vector testScores { 78, 94, 66, 77, 14 };
testScores[Students::stan] = 76; // 이제 stan(스탠)의 시험 점수를 수정합니다.
return 0;
}
이렇게 하면 배열의 각 칸이 누구의 점수인지 훨씬 쉽게 알 수 있죠!
게다가 열거자에 적힌 이름들은 기본적으로 '상수(constexpr)' 취급을 받기 때문에, 부호가 없는 숫자로 변환될 때 생기는 에러나 경고도 피할 수 있답니다.
일반 enum의 바탕이 되는 숫자 타입은 컴퓨터(컴파일러) 마음대로 결정됩니다.
양수만 될 수도 있고, 음수/양수 모두 되는 타입(signed)이 될 수도 있어요.
열거형 안의 이름표들을 직접 쓸 때는 알아서 처리되니 문제가 없지만, 열거형 '변수'를 따로 만들어서 쓰려고 하면 문제가 생길 수도 있습니다.
만약 기본 타입이 음수/양수 모두 되는 타입으로 잡혀 있다면, 이걸 배열 인덱스로 쓸 때 부호 변환 경고가 뜰 수 있어요.
#include <vector>
namespace Students
{
enum Names
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
}
int main()
{
std::vector testScores { 78, 94, 66, 77, 14 };
Students::Names name { Students::stan }; // 상수가 아님
testScores[name] = 76; // Student::Names의 기본 타입이 부호 있는(signed) 타입이라면 부호 변환 경고가 뜰 수 있습니다.
return 0;
}
이럴 때는 name을 상수로 만들거나, 아니면 아예 enum을 만들 때부터
"이 열거형은 무조건 부호 없는 양수(unsigned int)로 쓸 거야!" 라고 콕 짚어주면 해결됩니다.
#include <vector>
namespace Students
{
enum Names : unsigned int // 바탕 타입을 unsigned int로 명시적으로 지정합니다.
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
}
int main()
{
std::vector testScores { 78, 94, 66, 77, 14 };
Students::Names name { Students::stan }; // 상수가 아님
testScores[name] = 76; // name이 unsigned(부호 없음)이므로 부호 변환 경고가 발생하지 않습니다.
return 0;
}
눈치채셨나요? 아까부터 열거형 목록 맨 끝에 max_students라는 항목이 슬쩍 들어가 있었죠. 열거형은 따로 숫자를 안 적어주면 0부터 시작해서 1씩 커집니다. 그래서 앞에 5명의 학생이 있으면, 마지막에 오는 max_students는 자동으로 5 가 됩니다!
이걸 '개수 세기용 열거자' 라고 부를게요. 이 값은 전체 학생 수가 몇 명인지 알고 싶을 때 요긴하게 쓰입니다.
#include <iostream>
#include <vector>
namespace Students
{
enum Names
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
// 앞으로 추가될 학생들은 여기에 넣으세요
max_students // 5
};
}
int main()
{
std::vector<int> testScores(Students::max_students); // 5개의 요소를 가진 벡터(배열)를 만듭니다
testScores[Students::stan] = 76; // 이제 stan(스탠)의 시험 점수를 수정합니다.
std::cout << "The class has " << Students::max_students << " students\n";
return 0;
}
이 방법은 정말 마법 같아요! 나중에 학생 한 명을 더 추가하고 싶으면, max_students 바로 윗줄에 이름만 적어주면 됩니다. 그러면 max_students 값도 자동으로 1 늘어나고, 배열 크기도 알아서 커지기 때문에 다른 코드를 손댈 필요가 전혀 없습니다.
#include <vector>
#include <iostream>
namespace Students
{
enum Names
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
wendy, // 5 (추가됨)
// 앞으로 추가될 학생들은 여기에 넣으세요
max_students // 이제 6이 됩니다
};
}
int main()
{
std::vector<int> testScores(Students::max_students); // 이제 6개의 요소를 할당합니다
testScores[Students::stan] = 76; // 여전히 잘 작동합니다
std::cout << "The class has " << Students::max_students << " students\n";
return 0;
}
보통은 처음부터 초기값을 쫙 나열해서 배열을 만드는 경우가 많아요. 이때 우리가 만든 '학생 수(개수 세기용 열거자)'와 '실제 배열에 들어간 점수의 개수'가 똑같은지 확인해 주는 것이 좋습니다. 만약 학생 이름은 새로 적어놓고 점수 적는 걸 깜빡했다면 여기서 딱 걸리게 되거든요!
#include <cassert>
#include <iostream>
#include <vector>
enum StudentNames
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
int main()
{
std::vector testScores { 78, 94, 66, 77, 14 };
// 시험 점수의 개수가 학생 수와 동일한지 확인합니다
assert(std::size(testScores) == max_students);
return 0;
}
팁
만약 일반 배열이 아니라 상수(constexpr) 배열을 쓰고 있다면static_assert를 써야 합니다.std::vector는 상수를 지원하지 않지만,std::array나 C 스타일 배열은 지원하거든요.
모범 사례
상수로 만든(constexpr) 배열의 길이를 확인할 때는 static_assert 를 사용하세요.
일반 배열의 길이를 확인할 때는 assert 를 사용하세요.
일반 enum은 이름들이 코드 이곳저곳에 섞여서 충돌할 위험이 있습니다.
그래서 C++에서는 좀 더 안전한 enum class를 쓰는 걸 추천하죠.
하지만 깐깐한 enum class는 자동으로 숫자로 변환되지 않기 때문에, 배열 인덱스로 바로 쓰려고 하면 에러를 뿜어냅니다.
#include <iostream>
#include <vector>
enum class StudentNames // 이제 enum class입니다
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
int main()
{
// 컴파일 에러: StudentNames에서 std::size_t로 변환할 수 없습니다.
std::vector<int> testScores(StudentNames::max_students);
// 컴파일 에러: StudentNames에서 std::size_t로 변환할 수 없습니다.
testScores[StudentNames::stan] = 76;
// 컴파일 에러: StudentNames에서 operator<<가 출력할 수 있는 타입으로 변환할 수 없습니다.
std::cout << "The class has " << StudentNames::max_students << " students\n";
return 0;
}
이걸 해결하는 가장 단순한 방법은 static_cast를 써서 강제로 숫자로 바꿔주는 건데... 코드가 너무 길어지고 못생겨집니다.
#include <iostream>
#include <vector>
enum class StudentNames
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
int main()
{
std::vector<int> testScores(static_cast<int>(StudentNames::max_students));
testScores[static_cast<int>(StudentNames::stan)] = 76;
std::cout << "The class has " << static_cast<int>(StudentNames::max_students) << " students\n";
return 0;
}
타자 치기 너무 귀찮죠? 더 좋은 방법은 단항 + 기호에 "이 기호를 붙이면 열거형을 숫자로 바꿔줘!"라는 특별한 기능을 심어두는(오버로딩) 겁니다.
#include <iostream>
#include <type_traits> // std::underlying_type_t를 사용하기 위해 필요
#include <vector>
enum class StudentNames
{
kenny, // 0
kyle, // 1
stan, // 2
butters, // 3
cartman, // 4
max_students // 5
};
// 단항 + 연산자를 오버로딩하여 StudentNames를 기본 바탕 타입으로 변환합니다
constexpr auto operator+(StudentNames a) noexcept
{
return static_cast<std::underlying_type_t<StudentNames>>(a);
}
int main()
{
std::vector<int> testScores(+StudentNames::max_students);
testScores[+StudentNames::stan] = 76;
std::cout << "The class has " << +StudentNames::max_students << " students\n";
return 0;
}
이렇게 하면 enum class 이름 앞에 +만 톡 붙여주면 되니까 훨씬 깔끔하죠!
하지만, 배열에 접근할 일이 너~무 많다면 매번 +를 붙이는 것도 일이 될 수 있으니, 그냥 안전한 공간(namespace나 class) 안에 일반 enum을 넣어서 쓰는 것도 좋은 선택이랍니다.
이번 장의 이전 레슨들에서는 컨테이너와 배열, 그리고 std::vector 에 대해 배웠어요.
배열 안의 데이터에 어떻게 접근하는지, 배열의 길이는 어떻게 아는지, 그리고 배열을 어떻게 쭉 훑어보는지(순회하는지)도 이야기했었죠. 예시로는 std::vector 를 사용했지만, 우리가 배운 개념들은 보통 모든 종류의 배열에 다 똑같이 써먹을 수 있는 것들이었어요.
이제 남은 레슨들에서는 std::vector 가 다른 대부분의 배열들과 확연히 다르게 만들어주는 '단 하나의 엄청난 특징' 에 집중해 볼 거예요. 바로 '처음 만들어진 이후에도 스스로 크기를 늘리거나 줄일 수 있는 능력' 이랍니다!
대부분의 배열은 아주 큰 단점이 하나 있어요. 처음 만들 때 배열의 길이를 미리 딱 정해야 하고, 한 번 정해지면 절대 바꿀 수 없다는 거죠. 이런 배열들을 '고정 크기 배열' 이라고 부릅니다. std::array 와 C언어 스타일의 배열이 바로 이 고정 크기 배열에 속해요. 이건 다음 장에서 더 자세히 다룰게요.
반면에 std::vector 는 '동적 배열(Dynamic array)' 이에요. 필요할 때마다 크기를 자유자재로 바꿀 수 있는 배열이죠. 이 유연함이 바로 std::vector 를 아주 특별하게 만들어줍니다.
std::vector 크기 바꾸기std::vector 는 만들어진 후에도 resize() 라는 기능(멤버 함수)을 써서 원하는 새로운 길이로 크기를 바꿀 수 있어요. 코드로 볼까요?
#include <iostream>
#include <vector>
int main(){
std::vector v{ 0, 1, 2 }; // 3개의 요소를 가진 벡터 만들기
std::cout << "The length is: " << v.size() << '\n';
v.resize(5); // 5개의 요소로 크기 늘리기
std::cout << "The length is: " << v.size() << '\n';
for (auto i : v)
std::cout << i << ' ';
std::cout << '\n';
return 0;
}
이 코드를 실행하면 이렇게 출력돼요:
The length is: 3
The length is: 5
0 1 2 0 0
여기서 주목해야 할 아주 중요한 사실 두 가지가 있어요!
첫째, 크기를 늘렸을 때 원래 들어있던 데이터(0, 1, 2)는 지워지지 않고 그대로 유지 되었습니다!
둘째, 새로 생겨난 빈칸들은 기본값으로 알아서 채워집니다. 숫자의 경우 0 으로 채워지기 때문에, 새로 생긴 두 칸이 모두 0 이 된 것을 볼 수 있어요.
물론, 반대로 크기를 더 작게 줄일 수도 있습니다:
#include <iostream>
#include <vector>
void printLength(const std::vector<int>& v){
std::cout << "The length is: " << v.size() << '\n';
}
int main(){
std::vector v{ 0, 1, 2, 3, 4 }; // 처음 길이는 5
printLength(v);
v.resize(3); // 3개의 요소로 크기 줄이기
printLength(v);
for (int i : v)
std::cout << i << ' ';
std::cout << '\n';
return 0;
}
출력 결과:
The length is: 5
The length is: 3
0 1 2
std::vector 의 길이(Length) vs 용량(Capacity)12채의 집이 일렬로 늘어서 있다고 상상해 보세요. 우리는 집의 개수(길이)가 12채라고 말할 겁니다. 그런데 그 집들에 실제로 사람들이 살고 있는지 알고 싶다면... 직접 초인종을 누르며 돌아다녀 봐야 알 수 있겠죠. 즉, '길이' 만 알 때는 그저 '몇 개가 존재하는지'만 알 수 있습니다.
이번엔 계란판을 생각해 볼까요? 지금 계란 5개가 들어있습니다. 여기서 계란의 개수(길이)는 5개입니다. 하지만 여기서 우리가 신경 써야 할 개념이 하나 더 있어요. "이 계란판이 꽉 찬다면 최대 몇 개까지 담을 수 있을까?" 하는 거죠. 이 계란판의 '용량(Capacity)' 은 12입니다. 총 12개를 담을 수 있는 공간이 있는데 현재 5개만 쓰고 있는 거죠. 따라서 우리는 7개의 계란을 더 사 와도 넘치지 않게 담을 수 있습니다. 길이와 용량 두 가지를 모두 알면, '현재 몇 개가 있는지'와 '앞으로 몇 개를 더 넣을 공간이 있는지'를 구분할 수 있게 됩니다.
지금까지 우리는 std::vector 의 '길이' 에 대해서만 이야기했어요. 하지만 std::vector 에는 '용량' 이라는 것도 있습니다. 쉽게 말해, 용량 은 '벡터가 데이터를 담기 위해 미리 준비해 둔 메모리 빈 공간의 크기'이고, 길이 는 '그 공간 안에서 실제로 사용 중인 데이터의 개수'입니다.
용량이 5인 std::vector 는 요소 5개를 담을 공간을 미리 확보해 둔 상태예요. 만약 여기에 2개의 데이터만 들어있다면, 이 벡터의 길이(사이즈)는 2입니다. 나머지 3칸은 미리 자리를 잡아두긴 했지만 아직 쓰고 있지 않은 '예비 공간'인 셈이죠. 나중에 데이터가 더 들어오면 넘치지 않게 바로 쓸 수 있습니다.
핵심 포인트
- 벡터의 길이(Length) : 현재 "사용 중인" 데이터가 몇 개인가?
- 벡터의 용량(Capacity) : 현재 메모리에 "확보해 둔 빈 공간"이 총 몇 개인가?
std::vector 의 용량 확인하기capacity() 라는 함수를 쓰면 현재 벡터의 용량이 얼마인지 물어볼 수 있어요.
#include <iostream>
#include <vector>
void printCapLen(const std::vector<int>& v){
std::cout << "Capacity: " << v.capacity() << " Length:" << v.size() << '\n';
}
int main(){
std::vector v{ 0, 1, 2 }; // 처음 길이는 3
printCapLen(v);
for (auto i : v)
std::cout << i << ' ';
std::cout << '\n';
v.resize(5); // 5개의 요소로 크기 늘리기
printCapLen(v);
for (auto i : v)
std::cout << i << ' ';
std::cout << '\n';
return 0;
}
이 코드를 실행하면 컴퓨터마다 조금 다를 수 있지만 대략 이렇게 나옵니다:
Capacity: 3 Length: 3
0 1 2
Capacity: 5 Length: 5
0 1 2 0 0
처음에 3개의 데이터로 벡터를 만들었죠? 이때 벡터는 딱 3개가 들어갈 공간만 준비(용량 3)하고 3개를 모두 사용(길이 3)합니다.
그다음 resize(5) 를 불러서 길이를 5로 늘려달라고 주문했어요. 벡터는 현재 빈 공간이 3칸밖에 없는데 5칸이 필요해졌으니, 추가 데이터를 담기 위해 더 큰 새로운 공간을 구해야만 했습니다.
결과적으로 벡터는 5개를 담을 공간(용량 5)을 마련했고, 5개 모두 사용 중(길이 5)인 상태가 되었습니다.
평소에 코딩할 때는 capacity() 함수를 직접 쓸 일이 많지는 않겠지만, 벡터의 내부 공간이 어떻게 변하는지 눈으로 확인하기 위해 이번 레슨에서는 자주 써볼 거예요.
std::vector 가 관리하는 메모리 공간의 크기를 바꾸는 과정을 '재할당' 이라고 부릅니다. 이 과정은 마치 '새 집으로 이사하는 것'과 비슷해요.
겉보기에는 그냥 벡터가 쭉 늘어난 것처럼 보이지만, 컴퓨터 내부에서는 완전히 새로운 공간으로 싹 다 이사 를 한 거랍니다!
이사를 할 때 모든 짐을 다 싸서 옮겨야 하니 아주 힘들고 시간이 오래 걸리겠죠? 컴퓨터도 마찬가지라서, 메모리를 재할당하는 건 컴퓨터의 힘을 많이 쓰는(비용이 큰) 작업이에요. 그래서 가급적 이사를 너무 자주 하지 않도록 피하는 것이 좋습니다.
핵심 포인트
재할당은 컴퓨터에게 꽤 부담스러운 작업입니다.
불필요한 재할당은 피하는 것이 좋습니다.
std::vector 는 공간이 부족하면 알아서 이사(재할당)를 하긴 하지만, 웬만하면 이사하는 걸 무척 귀찮아합니다. (마치 "안 하는 게 낫겠습니다"라고 말하던 소설 주인공 바틀비처럼 말이죠!) 이사하는 데 에너지가 너무 많이 드니까요.
만약 벡터가 '용량'이라는 개념 없이 '길이'만 알고 있다면 어떻게 될까요? 크기를 1칸 늘리든 줄이든, 무언가 바뀔 때마다 매번 새집을 구해서 이사(재할당)를 해야 할 겁니다. 정말 끔찍하죠! 하지만 길이와 용량을 따로 관리하면, 벡터가 언제 진짜 이사가 필요한지 똑똑하게 판단할 수 있습니다.
아래 예시를 볼까요.
#include <iostream>
#include <vector>
void printCapLen(const std::vector<int>& v){
std::cout << "Capacity: " << v.capacity() << " Length:" << v.size() << '\n';
}
int main(){
// 길이가 5인 벡터 만들기
std::vector v{ 0, 1, 2, 3, 4 };
v = { 0, 1, 2, 3, 4 }; // 좋아요, 배열의 길이는 5
printCapLen(v);
for (auto i : v)
std::cout << i << ' ';
std::cout << '\n';
// 벡터의 크기를 3개의 요소로 줄이기
v.resize(3); // 여기에 3개의 요소로 이루어진 리스트를 할당할 수도 있습니다
printCapLen(v);
for (auto i : v)
std::cout << i << ' ';
std::cout << '\n';
// 다시 5개의 요소로 크기 늘리기
v.resize(5);
printCapLen(v);
for (auto i : v)
std::cout << i << ' ';
std::cout << '\n';
return 0;
}
결과는 이렇습니다:
Capacity: 5 Length: 5
0 1 2 3 4
Capacity: 5 Length: 3
0 1 2
Capacity: 5 Length: 5
0 1 2 0 0
처음 5개의 데이터를 넣었을 때 용량과 길이가 모두 5였습니다.
그다음 v.resize(3) 을 불러서 배열을 작게 줄여달라고 했죠. 길이는 3으로 줄었지만, 주목하세요! 용량은 여전히 5 입니다. 벡터가 집(메모리)을 버리고 이사(재할당)를 하지 않았다는 뜻이에요. 단순히 뒤쪽 빈방의 불만 꺼둔 셈이죠.
마지막으로 다시 v.resize(5) 를 불렀습니다. 벡터는 이미 용량이 5, 즉 빈방이 준비되어 있었기 때문에 이번에도 귀찮은 이사를 할 필요가 없었습니다. 그냥 꺼뒀던 불을 켜고 길이만 5로 되돌린 다음, 뒤쪽 두 칸을 기본값(0)으로 채워주기만 하면 끝이었죠.
길이와 용량을 따로 나눈 덕분에 우리는 불필요한 이사(재할당)를 2번이나 아낄 수 있었습니다. 나중에 데이터를 하나씩 하나씩 계속 추가해야 할 일이 생길 텐데, 그때마다 매번 이사하지 않아도 된다는 건 정말 엄청난 장점이에요.
핵심 포인트
용량을 길이와 분리해서 기억해 두면, 크기가 변할 때마다 매번 무리해서 재할당하는 것을 막을 수 있습니다.
여기서 초보자들이 흔히 하는 실수가 있어요! [] 기호나 at() 함수를 써서 벡터 안의 n번째 방에 들어갈 때, 쓸 수 있는 방 번호는 용량이 아니라 '길이' 까지만 허용됩니다.
위의 예시처럼 용량이 5이고 길이가 3인 상황을 생각해 보세요. 0번, 1번, 2번 방까지만 정상적으로 들어갈 수 있습니다. 용량이 5라고 해서 아직 사용하지도 않는 3번, 4번 방에 들어가려고 하면, 범위를 벗어났다는 에러(Out of bounds)가 나게 됩니다.
경고
데이터에 접근할 때 쓰는 방 번호(인덱스)는 반드시 0부터 벡터의 '길이' (용량 아님!) 사이여야 합니다!
std::vector 의 불필요한 용량 줄이기벡터의 크기를 늘리면 필요에 따라 용량도 같이 늘어난다고 했죠. 하지만 반대로 길이를 줄인다고 해서 확보해 둔 용량이 알아서 줄어들지는 않습니다.
데이터 몇 개 안 쓸 거 같아서 빈방을 굳이 줄이겠다고 힘들게 재할당(이사)을 하는 건 사실 별로 좋은 선택이 아니에요. 하지만 만약 요소가 100,000개 들어있던 초대형 벡터에서 99,990개를 지워버리고 10개만 남겼다면? 안 쓰는 빈방이 99,990개나 되니 메모리 낭비가 너무 심하겠죠!
이럴 때 메모리 낭비를 해결하기 위해 std::vector 에는 shrink_to_fit() (내 몸에 딱 맞게 줄여줘!) 이라는 기능이 있습니다. "현재 사용 중인 길이에 맞춰서 안 쓰는 빈 용량은 좀 버려줘"라고 부탁하는 기능이에요. 다만 이건 말 그대로 '부탁'일 뿐 강제성은 없어서, 컴퓨터(컴파일러)가 상황을 보고 굳이 안 해도 되겠다 싶으면 무시할 수도 있습니다. 하지만 보통은 부탁을 잘 들어줍니다.
예시를 볼까요:
#include <iostream>
#include <vector>
void printCapLen(const std::vector<int>& v){
std::cout << "Capacity: " << v.capacity() << " Length:" << v.size() << '\n';
}
int main(){
std::vector<int> v(1000); // 1000개의 요소가 들어갈 공간 할당하기
printCapLen(v);
v.resize(0); // 0개의 요소로 크기 줄이기
printCapLen(v);
v.shrink_to_fit();
printCapLen(v);
return 0;
}
결과는 이렇게 나옵니다:
Capacity: 1000 Length: 1000
Capacity: 1000 Length: 0
Capacity: 0 Length: 0
보시다시피, 길이를 0으로 줄였을 때는 용량이 그대로 1000이었지만, shrink_to_fit() 을 부르자 벡터가 불필요한 용량을 0으로 싹 줄여서 1000개짜리 빈 공간을 시스템에 예쁘게 돌려준 것을 확인할 수 있습니다!
여러분이 사용자가 여러 개의 값(예: 시험 점수들)을 입력하는 프로그램을 만든다고 상상해 보세요. 이때 사용자가 몇 개의 점수를 입력할지는 프로그램을 실행하기 전(컴파일 타임)에는 알 수 없고, 실행할 때마다 달라질 수 있습니다. 우리는 이 값들을 화면에 보여주거나 처리하기 위해 std::vector 에 저장할 거예요.
지금까지 배운 내용을 바탕으로 몇 가지 방법을 생각해 볼 수 있어요.
하지만 이 두 번째 방법의 단점은 사용자가 딱 30개까지만 입력할 수 있다는 거예요. 만약 30개보다 더 많이 입력하고 싶다면? 안타깝게도 방법이 없죠.
이 문제를 해결하기 위해 30개가 꽉 차면 벡터의 크기를 더 크게 늘리는 코드를 추가할 수도 있어요. 하지만 이렇게 되면 프로그램의 원래 목적(점수 처리)과 배열 크기 관리 코드가 섞이게 되어서, 프로그램이 훨씬 복잡해지고 버그가 생기기 쉬워집니다.
진짜 문제는 우리가 '사용자가 몇 개를 입력할지' 미리 짐작하려고 한다는 데 있어요. 입력할 개수를 미리 알 수 없는 상황에서는 훨씬 더 좋은 방법이 있답니다!
하지만 그 방법을 알아보기 전에, 잠깐 다른 이야기를 먼저 해볼게요.
비유를 하나 들어볼게요! 식당에 쌓여 있는 접시 무더기를 생각해 보세요. 왠지 모르겠지만 이 접시들은 엄청 무거워서 한 번에 딱 하나씩만 들 수 있다고 가정해 봅시다. 접시가 무겁고 쌓여 있기 때문에, 이 접시 더미를 다루는 방법은 딱 두 가지뿐이에요.
중간이나 맨 밑에서 접시를 빼거나 넣는 건 절대 안 돼요. 그러려면 한 번에 여러 개의 접시를 들어야 하니까요.
이렇게 스택(접시 더미)에 물건을 넣고 빼는 순서를 후입선출 (LIFO: Last-In, First-Out) 이라고 불러요. 즉, 가장 마지막에 들어간 접시가 가장 먼저 나오게 되는 구조랍니다.
프로그래밍에서 스택(Stack) 은 이 LIFO(후입선출) 방식으로 데이터를 넣고 빼는 보관함(컨테이너)을 말해요. 주로 push(푸시) 와 pop(팝) 이라는 두 가지 핵심 동작으로 작동합니다.
| 동작 이름 | 하는 일 | 필수 여부 | 참고 사항 |
|---|---|---|---|
| Push (푸시) | 스택 맨 위에 새 데이터를 넣습니다 | 예 | |
| Pop (팝) | 스택 맨 위에서 데이터를 빼냅니다 | 예 | 빼낸 데이터를 반환하거나 아무것도 반환하지 않을 수 있음(void) |
그 외에도 스택에서 유용하게 쓰이는 동작들이 더 있어요.
| 동작 이름 | 하는 일 | 필수 여부 | 참고 사항 |
|---|---|---|---|
| Top 또는 Peek | 스택 맨 위에 있는 데이터를 확인만 합니다 | 선택 사항 | 데이터를 빼내지(지우지)는 않음 |
| Empty | 스택이 비어있는지 확인합니다 | 선택 사항 | |
| Size | 스택에 데이터가 몇 개 있는지 셉니다 | 선택 사항 |
스택은 프로그래밍에서 정말 흔하게 쓰여요. 이전에 '호출 스택(call stack)'에 대해 배운 적이 있죠? 어떤 함수들이 호출되었는지 기록해 두는 곳인데, 이름 그대로 이것도 스택이랍니다! (네, 비밀을 밝힌 것치곤 좀 싱겁죠 ㅎㅎ). 함수가 실행되면 그 함수에 대한 정보가 스택의 맨 위에 추가(Push)되고, 함수가 끝나면 맨 위에서 제거(Pop)돼요. 그래서 호출 스택의 맨 위는 항상 '지금 당장 실행 중인 함수'를 나타냅니다.
Push와 Pop이 어떻게 작동하는지 순서대로 볼까요?
(스택: 비어있음)
Push 1 (스택: 1)
Push 2 (스택: 1 2)
Push 3 (스택: 1 2 3)
Pop (스택: 1 2)
Push 4 (스택: 1 2 4)
Pop (스택: 1 2)
Pop (스택: 1)
Pop (스택: 비어있음)
어떤 프로그래밍 언어들은 스택을 완전히 별개의 전용 보관함으로 만들어 둬요. 하지만 이러면 좀 불편할 때가 있어요. 예를 들어, 스택을 건드리지 않고 안에 있는 값들만 쭉 화면에 출력하고 싶을 때, 순수하게 스택 기능만 있으면 그렇게 하기가 어렵거든요.
그래서 C++ 에서는 스택처럼 쓸 수 있는 기능(멤버 함수)들을 이미 있는 보관함들( std::vector , std::deque , std::list 등)에 추가해 두었어요. 이 보관함들은 원래 끝부분에서 데이터를 빠르게 넣고 빼는 데 특화되어 있거든요. 덕분에 원래의 편리한 기능들을 그대로 쓰면서 스택처럼 활용할 수도 있답니다.
아까 들었던 접시 더미 비유도 좋지만, 배열(array)을 이용해 스택을 어떻게 만드는지 이해하기 위해 더 찰떡인 비유를 하나 들어볼게요.
위로 쭉 쌓아 올린 우편함들을 상상해 보세요. 각 우편함에는 편지를 딱 하나씩만 넣을 수 있고, 처음엔 다 비어있어요. 이 우편함들은 서로 단단히 못 박혀 있고, 맨 위쪽 우편함에는 독이 묻은 가시가 돋아 있어서 새로운 우편함을 중간이나 맨 위에 더 끼워 넣을 수는 없다고 쳐볼게요. (우편함의 전체 개수를 늘릴 수 없다는 뜻이에요!)
우편함 개수를 늘릴 수 없는데 어떻게 스택처럼 쓸 수 있을까요?
즉, 표시보다 아래에 있는 것들은 '스택에 들어있는 데이터'이고, 표시와 그 위에 있는 우편함들은 '비어있는 공간'인 셈이죠.
자, 이제 여기서 '표시'를 length (데이터 개수, 길이)라고 부르고, 전체 '우편함의 개수'를 capacity (용량)라고 불러봅시다!
이번 레슨의 나머지 부분에서는 std::vector 의 스택 기능이 어떻게 작동하는지 알아보고, 마지막으로 처음에 말했던 '점수 입력받기' 문제를 이 방법으로 어떻게 멋지게 해결하는지 보여드릴게요.
std::vector 에서 스택 기능은 다음 함수들을 통해 사용할 수 있어요.
| 함수 이름 | 스택 동작 | 하는 일 | 참고 사항 |
|---|---|---|---|
push_back() | Push | 스택 맨 위에 새 데이터를 넣습니다 | 벡터의 맨 끝에 요소를 추가합니다 |
pop_back() | Pop | 스택 맨 위에서 데이터를 빼냅니다 | 반환값은 없고(void), 벡터 맨 끝 요소를 지웁니다 |
back() | Top / Peek | 스택 맨 위에 있는 데이터를 확인합니다 | 데이터를 지우지는 않습니다 |
emplace_back() | Push | push_back()의 다른 형태인데 더 효율적일 때가 있습니다 (아래 참고) | 벡터의 맨 끝에 요소를 추가합니다 |
그럼 이 함수들을 사용하는 예제 코드를 살펴볼까요?
#include <iostream>
#include <vector>
void printStack(const std::vector<int>& stack)
{
if (stack.empty()) // stack.size == 0 이라면
std::cout << "비어있음";
for (auto element : stack)
std::cout << element << ' ';
// \t 는 텍스트 줄을 맞추기 위한 탭(tab) 문자입니다
std::cout << "\t용량(Capacity): " << stack.capacity() << " 길이(Length) " << stack.size() << "\n";
}
int main()
{
std::vector<int> stack{}; // 빈 스택 만들기
printStack(stack);
stack.push_back(1); // push_back() 은 스택에 요소를 밀어 넣습니다(Push)
printStack(stack);
stack.push_back(2);
printStack(stack);
stack.push_back(3);
printStack(stack);
std::cout << "맨 위(Top): " << stack.back() << '\n'; // back() 은 마지막 요소를 반환합니다
stack.pop_back(); // pop_back() 은 스택에서 요소를 빼냅니다(Pop)
printStack(stack);
stack.pop_back();
printStack(stack);
stack.pop_back();
printStack(stack);
return 0;
}
(GCC나 Clang 컴파일러에서는 이렇게 출력돼요)
비어있음 용량(Capacity): 0 길이(Length): 0
1 용량(Capacity): 1 길이(Length): 1
1 2 용량(Capacity): 2 길이(Length): 2
1 2 3 용량(Capacity): 4 길이(Length): 3
맨 위(Top):3
1 2 용량(Capacity): 4 길이(Length): 2
1 용량(Capacity): 4 길이(Length): 1
비어있음 용량(Capacity): 4 길이(Length): 0
기억나시나요? 여기서 길이(length)는 벡터 안에 있는 데이터의 개수, 즉 우리 스택에 쌓인 데이터의 개수를 의미해요.
대괄호 operator[] 나 at() 함수를 쓸 때와 다르게, push_back() 이나 emplace_back() 을 사용하면 벡터의 길이(length)가 자동으로 늘어나요. 만약 데이터를 넣을 공간(capacity, 용량)이 부족하면, 공간을 더 넓히는 재할당(reallocation) 작업이 자동으로 일어납니다.
위 예제에서도 공간이 3번이나 재할당되었죠 (용량이 0에서 1로, 1에서 2로, 2에서 4로 늘어났어요).
핵심 포인트
push_back()과emplace_back()은 std::vector 의 길이를 늘려줍니다. 그리고 남은 용량이 부족하면 알아서 더 큰 공간으로 이사(재할당)를 갑니다.
위의 출력 결과를 보면, 마지막으로 공간이 늘어날 때 요소를 1개만 더 넣었는데도 용량이 2에서 4로 껑충 뛰었죠? push_back으로 인해 공간이 늘어나야 할 때, std::vector 는 보통 앞으로 데이터가 더 들어올 것을 대비해서 공간을 좀 더 여유 있게 잡아둡니다. 그래야 다음에 데이터가 들어올 때 또 이사(재할당)를 가는 번거로움을 줄일 수 있거든요.
얼마나 여유 있게 잡을지는 여러분이 쓰는 컴파일러마다 조금씩 달라요.
그래서 어떤 컴파일러를 쓰느냐에 따라 출력 결과의 용량 부분이 조금 다르게 보일 수 있습니다.
벡터 공간을 다시 할당(재할당)하는 건 컴퓨터 입장에서 꽤 힘든 일(계산 비용이 높음)이에요. 그래서 가능하면 재할당이 안 일어나게 하는 게 좋겠죠? 방금 본 예제에서 처음부터 용량을 딱 3으로 맞춰놓고 시작했다면 3번이나 이사를 가는 일을 피할 수 있었을 거예요.
그럼 첫 번째 예제의 시작 부분을 이렇게 고쳐보면 어떨까요?
std::vector<int> stack(3); // 괄호를 써서 초기화하면 벡터의 용량을 3으로 설정합니다
이렇게 고치고 다시 실행하면 이런 결과가 나옵니다.
0 0 0 용량(Capacity): 3 길이(Length): 3
0 0 0 1 용량(Capacity): 6 길이(Length): 4
0 0 0 1 2 용량(Capacity): 6 길이(Length): 5
0 0 0 1 2 3 용량(Capacity): 6 길이(Length): 6
맨 위(Top): 3
0 0 0 1 2 용량(Capacity): 6 길이(Length): 5
0 0 0 1 용량(Capacity): 6 길이(Length): 4
0 0 0 용량(Capacity): 6 길이(Length): 3
앗, 뭔가 이상하죠? 스택 맨 밑에 0이 세 개나 깔려버렸어요! 이유는 이렇습니다. 괄호로 초기화해서 벡터 크기를 정해주거나 resize() 함수를 사용하면, 용량(Capacity)뿐만 아니라 길이(Length)까지 함께 3으로 설정되어 버려요. 즉, 우리가 원했던 대로 빈 상자 3개가 준비된 게 아니라, 이미 0이라는 값이 들어있는 꽉 찬 상자 3개가 생겨버린 거죠. 그래서 우리가 push_back으로 넣은 값들은 그 0들 위에 차곡차곡 쌓이게 된 거예요.
resize() 함수를 써서 길이를 미리 늘려두는 건, 대괄호([ ])를 써서 특정 위치의 값을 콕 집어서 바꿀 때는 아주 좋아요. 하지만 스택처럼 맨 위에 계속 쌓아 올리려고 할 때는 이렇게 골칫거리가 됩니다.
우리가 진짜 원하는 건, 길이(Length)는 0으로 놔둔 채로 용량(Capacity, 여유 공간)만 늘려두는 기능이에요. 그래야 쓸데없이 0이 추가되지 않으면서 나중에 재할당되는 것도 막을 수 있으니까요.
이럴 때 쓰는 게 바로 reserve() 라는 함수예요! 이 함수는 현재 길이(데이터 개수)는 건드리지 않고, std::vector 의 전체 용량만 넉넉하게 잡아줍니다.
아까와 똑같은 코드에 reserve() 만 추가해서 용량을 설정해 볼게요.
#include <iostream>
#include <vector>
void printStack(const std::vector<int>& stack)
{
if (stack.empty()) // stack.size == 0 이라면
std::cout << "비어있음";
for (auto element : stack)
std::cout << element << ' ';
// \t 는 텍스트 줄을 맞추기 위한 탭(tab) 문자입니다
std::cout << "\t용량(Capacity): " << stack.capacity() << " 길이(Length) " << stack.size() << "\n";
}
int main()
{
std::vector<int> stack{};
printStack(stack);
stack.reserve(6); // 6개의 요소가 들어갈 공간을 미리 확보합니다 (하지만 길이는 바꾸지 않아요)
printStack(stack);
stack.push_back(1);
printStack(stack);
stack.push_back(2);
printStack(stack);
stack.push_back(3);
printStack(stack);
std::cout << "맨 위(Top): " << stack.back() << '\n';
stack.pop_back();
printStack(stack);
stack.pop_back();
printStack(stack);
stack.pop_back();
printStack(stack);
return 0;
}
실행 결과는 다음과 같습니다.
비어있음 용량(Capacity): 0 길이(Length): 0
비어있음 용량(Capacity): 6 길이(Length): 0
1 용량(Capacity): 6 길이(Length): 1
1 2 용량(Capacity): 6 길이(Length): 2
1 2 3 용량(Capacity): 6 길이(Length): 3
맨 위(Top): 3
1 2 용량(Capacity): 6 길이(Length): 2
1 용량(Capacity): 6 길이(Length): 1
비어있음 용량(Capacity): 6 길이(Length): 0
보이시나요? reserve(6) 을 불렀더니 용량은 6으로 늘어났지만 길이는 0으로 그대로 유지되었어요. 이미 여유 공간이 6이나 있으니까, 데이터를 넣을 때 더 이상 번거로운 재할당(이사)이 일어나지 않게 되었죠!
핵심 포인트
resize() 함수는 벡터의 길이를 바꿉니다 (필요하면 용량도 늘림).reserve() 함수는 벡터의 용량만 바꿉니다 (길이는 안 건드림).꿀팁
std::vector 의 요소를 늘리고 싶을 때:
[ ] 를 써서 특정 위치(인덱스)에 접근할 계획이라면 resize() 를 쓰세요. 길이를 늘려줘야 오류가 안 나요.push_back() 으로 맨 끝에 밀어 넣을 계획이라면 reserve() 를 쓰세요. 길이 변경 없이 여유 공간만 확보해 줍니다.push_back() 과 emplace_back() 둘 다 스택에 데이터를 넣는 역할을 해요. 이미 만들어져 있는 변수(객체)를 넣을 때는 둘이 똑같이 작동하니까, 좀 더 익숙한 push_back() 을 쓰는 게 좋습니다.
하지만, 스택에 넣기 위해서 일회용으로 쓰일 데이터(임시 객체)를 그 자리에서 바로 만들어서 넣을 때는 emplace_back() 이 훨씬 더 빠르고 효율적이에요.
#include <iostream>
#include <string>
#include <string_view>
#include <vector>
class Foo
{
private:
std::string m_a{};
int m_b{};
public:
Foo(std::string_view a, int b)
: m_a { a }, m_b { b }
{}
explicit Foo(int b)
: m_a {}, m_b { b }
{};
};
int main()
{
std::vector<Foo> stack{};
// 이미 만들어진 객체가 있을 때는, push_back 과 emplace_back 의 효율이 비슷합니다
Foo f{ "a", 2 };
stack.push_back(f); // 이 방법을 더 권장합니다
stack.emplace_back(f);
// 밀어 넣기 위해 임시 객체를 새로 만들어야 할 때는, emplace_back 이 더 효율적입니다
stack.push_back({ "a", 2 }); // 임시 객체를 먼저 만든 다음, 벡터 안으로 복사해 넣습니다
stack.emplace_back("a", 2); // 값만 전달해서 벡터 안에서 객체를 직접 만들어 버립니다 (복사할 필요 없음)
// push_back 은 '명시적(explicit) 생성자'를 무시할 수 없지만, emplace_back 은 사용할 수 있습니다
stack.push_back({ 2 }); // 컴파일 에러: Foo(int) 생성자는 explicit 으로 설정되어 있습니다
stack.emplace_back(2); // 이건 가능합니다 (ok)
return 0;
}
위 코드에서 push_back({ "a", 2 }) 를 쓰면, 컴퓨터는 몰래 임시 Foo 객체를 하나 만들고, 그걸 다시 벡터 안으로 '복사'하는 과정을 거쳐요. 문자가 길어지거나 복잡한 데이터라면 이 '복사' 과정에서 시간이 꽤 걸릴 수 있어요.
반면에 emplace_back("a", 2) 를 쓰면, 굳이 임시 객체를 밖에서 만들 필요 없이 재료("a"와 2)만 쓱 넘겨줍니다. 그러면 벡터가 자기 집 안에서 그 재료를 가지고 직접 요리(객체 생성)를 해버리죠. 복사할 필요가 없으니 당연히 더 빠르겠죠! (이걸 완벽한 전달, perfect forwarding 이라고 불러요).
다만 주의할 점은, push_back() 은 좀 엄격해서 explicit 이라고 표시된 특별한 생성자는 알아서 쓰지 않지만, emplace_back() 은 그걸 가져다 써버릴 수 있어요. 그래서 자칫 의도치 않은 방식으로 값이 바뀌어 들어가는 실수를 할 수 있어서 약간 더 위험할 수 있습니다.
(참고로 C++20 이전 버전에서는 emplace_back() 이 일부 초기화 방식을 지원하지 않았습니다.)
모범 사례 (Best Practice)
explicit 생성자를 꼭 써야 할 때는 emplace_back() 을 사용하세요.push_back() 을 사용하는 것을 추천합니다.자, 이제 맨 처음에 이야기했던 '점수 입력받기' 문제를 어떻게 해결해야 할지 감이 오시죠?
std::vector 에 데이터가 몇 개나 들어올지 미리 알 수 없다면, 스택 기능(push_back)을 써서 데이터를 차곡차곡 쌓아 넣는 게 정답입니다!
예제 코드를 볼까요?
#include <iostream>
#include <limits>
#include <vector>
int main()
{
std::vector<int> scoreList{};
while (true)
{
std::cout << "점수를 입력하세요 (끝내려면 -1 입력): ";
int x{};
std::cin >> x;
if (!std::cin) // 잘못된 입력 처리 (예: 숫자가 아닌 문자 입력)
{
std::cin.clear();
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
continue; // 다시 입력받도록 루프 처음으로 돌아감
}
// 사용자가 다 입력했다면(-1), 반복문을 빠져나갑니다
if (x == -1)
break;
// 사용자가 정상적인 점수를 입력했다면, 벡터에 밀어 넣습니다(push)
scoreList.push_back(x);
}
std::cout << "입력하신 점수 목록: \n";
for (const auto& score : scoreList)
std::cout << score << ' ';
return 0;
}
이 프로그램은 사용자에게 시험 점수를 계속 입력받아서 벡터에 차곡차곡 추가해요. 사용자가 점수 입력을 다 마치면(-1 입력), 벡터에 들어있는 모든 값을 쭉 보여줍니다.
놀랍지 않나요? 우리는 코드를 짜면서 점수가 몇 개인지 숫자를 세지도 않았고, 배열의 크기(길이)를 계산하거나 위치(인덱스)를 다룰 필요도 전혀 없었어요! 우리는 그저 '점수를 받아서 저장한다'는 핵심 논리에만 집중했고, 골치 아픈 저장 공간 관리는 전부 벡터가 알아서 처리해 주었답니다!
std::vector<bool>이전 O.1 레슨(비트 플래그와 std::bitset을 통한 비트 조작)에서, 우리는 std::bitset 이 8개의 불리언(Boolean, 참/거짓) 값을 단 하나의 바이트(1 byte) 안에 꽉꽉 압축해 넣는 방법에 대해 배웠습니다. 그리고 이 비트들은 std::bitset 의 기능을 통해 쉽게 수정할 수 있었죠.
그런데 std::vector 에도 아주 흥미로운 비밀 무기가 하나 숨겨져 있습니다. 바로 std::vector<bool> 이라는 특별한 버전인데요! 이것도 마찬가지로 8개의 참/거짓 값을 1바이트에 압축해서 메모리 공간을 훨씬 효율적으로 사용할 수 있게 해줍니다.
고급 학습자를 위한 참고
템플릿 클래스가 특정 타입(여기서는bool)에 대해 아예 다른 방식으로 동작하도록 만들어진 것을 클래스 템플릿 특수화(Class template specialization) 라고 부릅니다. 이 내용은 나중에 26.4 레슨에서 더 자세히 다룰 예정입니다.
하지만 비트 조작을 위해 만들어진 전문 도구인 std::bitset 과는 다르게, std::vector<bool> 에는 비트를 직접 만지는 섬세한 기능(멤버 함수)들이 빠져있습니다.
std::vector<bool> 사용해 보기대부분의 상황에서 std::vector<bool> 은(는) 평범한 벡터와 똑같이 작동합니다.
#include <iostream>
#include <vector>
int main()
{
std::vector<bool> v { true, false, false, true, true };
for (int i : v)
std::cout << i << ' ';
std::cout << '\n';
// 인덱스 4의 불리언 값을 false로 변경합니다.
v[4] = false;
for (int i : v)
std::cout << i << ' ';
std::cout << '\n';
return 0;
}
작성자의 64비트 컴퓨터에서 이 코드를 실행하면 다음과 같이 출력됩니다.
1 0 0 1 1
1 0 0 1 0
std::vector<bool> 의 치명적인 단점들 (주의사항)공간을 아껴준다는 장점이 있지만, std::vector<bool> 을(를) 사용할 때는 반드시 알아둬야 할 중요한 단점들이 있습니다.
bool 값을 저장하는 것도 아닙니다 (단순한 비트 조각들을 모아둔 것에 불과합니다).이름은 벡터라서 평소에는 벡터처럼 행동하지만, C++의 다른 표준 기능들과 완벽하게 어울리지 못합니다. 즉, 다른 타입에서는 멀쩡하게 작동하던 코드가 std::vector<bool> 에서는 에러를 뿜어낼 수 있다는 뜻입니다.
예를 들어, 아래 코드는 T 가 bool 이 아닌 다른 모든 타입일 때는 잘 작동합니다.
template<typename T>
void foo( std::vector<T>& v )
{
T& first = v[0]; // 첫 번째 요소에 대한 참조를 가져옵니다.
// first를 사용하여 무언가를 수행합니다.
}
std::vector<bool> 은 피하세요!요즘 C++ 개발자들 사이의 공통된 의견은 "웬만하면 std::vector 은 쓰지 말자!" 입니다. 메모리를 조금 아껴서 얻는 이득보다, 이게 '진짜 컨테이너'가 아니기 때문에 생기는 골칫거리와 호환성 문제가 훨씬 크기 때문입니다.
안타깝게도 이 압축 버전의 std::vector<bool> 은(는) 기본적으로 켜져 있고, 이걸 끄고 진짜 bool 을 담는 평범한 벡터로 되돌릴 방법이 없습니다. 그래서 아예 C++ 표준에서 이 기능을 없애버리자는(deprecate) 목소리도 나오고 있으며, 나중에는 std::dynamic_bitset 같은 새로운 형태의 기능으로 교체하려는 작업이 진행 중입니다.
초보자를 위한 상황별 추천 가이드
그렇다면 대신 무엇을 써야 할까요? 다음과 같이 사용하는 것을 추천합니다.
std::bitset 사용하기: 필요한 비트의 개수를 코드를 짤 때 이미 알고 있고, 저장할 데이터가 엄청나게 많지 않을 때 (예: 64k 이하) 아주 좋습니다.std::vector<char> 사용하기: 크기를 자유롭게 늘렸다 줄였다 해야 하는 참/거짓 목록이 필요하고, 메모리 공간을 한계까지 쥐어짜듯 아낄 필요가 없다면 이것을 강력히 추천합니다! 완벽하게 일반 벡터처럼 작동해서 에러 걱정이 없습니다.boost::dynamic_bitset 같은 믿을 만한 외부 도구를 쓰세요. 이런 도구들은 가짜 표준 컨테이너인 척하지 않아서 안전합니다.가장 좋은 습관 (Best practice)
std::vector<bool>보다는constexpr std::bitset이나std::vector<char>, 또는 외부 라이브러리의 동적 비트셋을 우선적으로 사용하세요!
격려의 한마디
이번 장은 결코 쉽지 않았을 거예요! 우리는 정말 많은 내용을 다루었고, C++의 까다로운 부분들도 파헤쳐 보았습니다. 포기하지 않고 끝까지 오신 것을 축하드려요!
배열 은 여러분의 C++ 프로그램이 엄청난 능력을 발휘할 수 있게 해주는 핵심 열쇠랍니다.
std::vector, 그리고 std::array 입니다.<vector> 라는 헤더 파일을 불러와서 사용하며, 꺾쇠 < > 안에 어떤 타입의 데이터를 담을지 적어줍니다. 예를 들어 std::vector<int> 는 '정수를 담는 벡터 보관함'을 만들겠다는 뜻이에요.{ }를 사용해서 안에 들어갈 값들을 바로 채워 넣을 수 있는데, 이를 리스트 생성자 를 사용한 초기화라고 합니다.[]를 쓰고, 그 안에 몇 번째 값을 꺼낼지 번호를 적습니다. 이 번호를 인덱스 (Index) 라고 해요.[]는 우리가 실수로 배열의 크기를 벗어난 엉뚱한 번호를 입력해도 경고해주지 않습니다. (이를 경계 검사 (Bounds checking) 를 하지 않는다고 해요). 범위를 벗어난 번호를 찾으려 하면 프로그램이 알 수 없는 오류를 일으킬 수 있으니 조심해야 합니다!리스트 초기화 주의사항
std::vector v1 { 5 }; // 값 '5'가 들어있는 1개의 요소를 가진 벡터를 정의합니다.
std::vector v2 ( 5 ); // 기본값으로 초기화된 5개의 요소를 가진 벡터를 정의합니다.
std::vector 는 내용을 바꿀 수 없는 상수(const)로 만들 수는 있지만, 컴파일 타임 상수(constexpr)로는 만들 수 없습니다.std::size_t라는 타입을 의미해요). 사용할 때는 std::vector<int>::size_type 처럼 전체 이름을 길게 적어줘야 합니다.size() 함수를 쓰면 됩니다. C++20부터는 std::ssize() 라는 함수도 추가되었는데, 이 함수는 크기를 일반적인 정수형(음수도 표현 가능한 타입)으로 알려줘서 수학 계산을 할 때 훨씬 편합니다.[] 대신 at() 이라는 함수를 사용해서 데이터를 꺼낼 수도 있습니다. 이 함수는 프로그램이 실행될 때 번호가 범위를 벗어나지 않았는지 안전하게 검사해 줍니다. 범위를 벗어나면 에러(예외)를 발생시키고 프로그램이 안전하게 멈추도록 도와주죠.std::vector 도 함수에 전달할 수 있습니다. 하지만 그냥 전달하면 보관함 전체를 통째로 복사하게 되어 컴퓨터가 무척 힘들어합니다. 그래서 불필요한 복사를 막기 위해 보통 참조 (Reference) 라는 방식을 사용해서 원본의 '위치'만 알려주는 식으로 전달합니다.std::vector 나 std::string 같은 타입은 함수에서 반환될 때 똑똑하게도 이 '이동' 방식을 사용합니다.auto) 기능을 활용하세요. 값을 바꿔야 하는 게 아니라면 항상 const auto& 를 사용하는 습관을 들이는 것이 좋습니다. 이렇게 하면 불필요한 데이터 복사를 막아줍니다.std::vector 가 바로 이 동적 배열이라서 엄청난 인기를 누리는 것이죠! resize() 함수를 부르면 언제든 원하는 길이로 바꿀 수 있습니다.std::vector 는 스스로 더 큰 새 보관함을 구해와서 기존 데이터들을 모두 새 보관함으로 이사시킵니다. 이를 재할당 (Reallocation) 이라고 해요. 이사는 힘든 작업이니 가급적 적게 일어나는 게 좋겠죠?shrink_to_fit() 함수를 쓰면, 쓰지 않고 남은 빈 공간을 없애서 보관함 크기를 실제 내용물에 딱 맞게 줄여달라고 요청할 수 있습니다.std::vector 는 이 스택처럼 쓸 수 있습니다! 맨 끝에 데이터를 추가할 때 push_back() 이나 emplace_back() 함수를 쓰면 됩니다.emplace_back() 이 조금 더 빠르고 똑똑하게 동작합니다. 그 외의 평범한 상황에서는 push_back() 을 쓰시면 됩니다.공간 늘리기 팁:
resize() 함수: 배열의 '실제 길이'를 늘릴 때 사용합니다. (데이터가 실제로 추가됨)reserve() 함수: 데이터는 놔두고 '빈 공간(용량)'만 미리 넉넉하게 확보해 둘 때 사용합니다. (이사를 자주 안 가도록 미리 큰 집을 구하는 것과 같아요).<bool>bool) 값을 담기 위한 std::vector<bool> 이라는 특별한 녀석이 있습니다. 이 녀석은 메모리 공간을 아주 쪼잔하게 아끼려고 8개의 값을 1바이트 안에 억지로 구겨 넣는 꼼수를 부립니다.std::vector<bool> 은 가급적 피하고 사용하지 않는 것이 속 편합니다!