1차원 배열
C++ 내장 1차원 배열의 선언·초기화·인덱스·순회·크기 정보 경계를 익히고 std::array, std::vector, std::span과 선택 기준을 비교합니다.
학생 다섯 명의 점수를 저장하려고 score1부터 score5까지 변수를 다섯 개 만들면, 합계를 구할 때도 다섯 이름을 모두 적어야 하고 학생 수가 늘면 코드를 고쳐야 합니다. 같은 타입의 값을 정해진 개수만큼 한 이름으로 묶어 두면, 4장에서 배운 반복문으로 차례로 다룰 수 있습니다. 이렇게 묶은 것이 배열(array)입니다.
이 글은 C++에 내장된 1차원 배열의 선언과 초기화, 인덱스로 원소를 읽고 쓰는 방법, 범위 밖 접근의 위험, 원소 개수를 구하는 방법을 봅니다. 끝으로 같은 일을 더 안전하게 하는 표준 라이브러리의 std::array, std::vector, std::span을 소개합니다.
배열의 세 가지 성질
int scores[5]처럼 선언한 내장 배열은 세 가지 성질을 가집니다. 모든 원소의 타입이 int로 같고, 다섯 개의 원소가 메모리에 빈틈없이 이어서 저장되며, 원소 번호인 인덱스(index)는 0부터 4까지입니다.
원소가 이어서 저장되므로 인덱스만 알면 그 원소의 위치를 바로 계산할 수 있습니다. 하지만 배열은 인덱스가 0부터 4 사이인지 스스로 검사하지 않습니다. 원소가 몇 개인지와 인덱스가 올바른지는 코드를 쓰는 사람이 지켜야 합니다.
선언과 크기
배열은 타입 이름[크기];의 형식으로 선언합니다. 크기는 컴파일할 때 값이 정해지는 0보다 큰 상수식이어야 하므로, 리터럴이나 constexpr 변수를 쓸 수 있습니다.
#include <cstddef>
constexpr std::size_t student_count = 5;
int scores[student_count]{};
double temperatures[30]{};
char grades[10]{};std::size_t는 크기와 개수를 나타내는 부호 없는 정수 타입입니다. 실행 중에 입력받은 값처럼 컴파일할 때 정해지지 않는 변수는 표준 C++에서 배열 크기로 쓸 수 없습니다. GCC는 int n = 5; int a[n]; 같은 가변 길이 배열(VLA)을 확장 기능으로 받아들이지만 표준이 아니므로, 실행 중에 길이가 정해지는 데이터에는 뒤에서 볼 std::vector를 씁니다.
지역 배열을 int raw[5];처럼 중괄호 없이 선언하면 원소의 값이 정해지지 않습니다. 값을 넣기 전에 읽으면 안 되므로, 특별한 이유가 없으면 int raw[5]{};처럼 중괄호를 붙여 0으로 시작합니다.
리스트 초기화
중괄호 안의 값은 0번 원소부터 차례로 들어갑니다. 아래 선언들은 값을 모두 주는 경우, 크기를 생략하는 경우, 값을 일부만 주는 경우, 빈 중괄호를 쓰는 경우를 차례로 보여 주며, 값을 일부만 주거나 하나도 주지 않은 배열이 어떻게 채워지는지는 줄 끝의 주석에 적어 두었습니다. 주석으로 막아 둔 마지막 줄은 컴파일되지 않는 예입니다.
int full[5]{85, 92, 78, 95, 88};
int inferred[]{1, 2, 3, 4, 5}; // 값 5개로 크기 5를 정함
int partial[5]{85, 92}; // 85 92 0 0 0
int zeros[5]{}; // 0 0 0 0 0
// int too_many[2]{1, 2, 3}; // 컴파일 오류: 값이 원소보다 많음크기를 생략하면 중괄호 안의 값 개수가 크기가 됩니다. 값이 원소보다 적으면 나머지 원소는 빈 중괄호로 초기화한 것처럼 처리되어, int나 double 같은 산술 타입은 0이 됩니다. 아래 그림은 다섯 값을 모두 준 scores와 두 값만 준 partial이 각 인덱스에 어떻게 배치되는지 보여 줍니다.
C++ · BUILT-IN ARRAY OBJECT
배열은 0부터 N − 1까지의 연속 원소를 다룬다
int scores[5]는 같은 타입의 원소 5개를 연속해서 소유합니다. 원소 번호는 0부터 4까지이며, 마지막 유효 인덱스는 N - 1입니다.
| 선언 | [0] |
[1] |
[2] |
[3] |
[4] |
|---|---|---|---|---|---|
int scores[5]{85, 92, 78, 95, 88}; |
85 | 92 | 78 | 95 | 88 |
int partial[5]{85, 92}; |
85 | 92 | 0 | 0 | 0 |
전체 초기화
int scores[5]{
85, 92, 78, 95, 88
};
index 0 1 2 3 4
value 85 92 78 95 88
부분 초기화
int partial[5]{85, 92};
index 0 1 2 3 4
value 85 92 0 0 0
명시하지 않은 int 원소는 0이 됩니다. 크기보다 많은 초기값은 ill-formed입니다.
읽기와 쓰기는 한 칸을 가리킨다
scores[2] // 78 읽기
scores[1] = 90; // 둘째 칸 쓰기
변경 뒤: 85 · 90 · 78 · 95 · 88
scores[0]이 첫 원소이고 scores[4]가 마지막 원소입니다.
순회 조건이 경계를 만든다
for (std::size_t i = 0;
i < std::size(scores);
++i) {
use(scores[i]);
}
for (int value : scores) {
use(value);
}
i < N은 N - 1에서 멈춥니다. scores[5]를 평가해 원소처럼 읽거나 쓰면 범위 밖 접근이며 동작이 정의되지 않습니다.
경계 검사 없음 연속 저장은 주소 관계를 보장하지만 성능 수치나 자동 범위 검사를 보장하지는 않습니다.
원소 읽기, 쓰기, 순회
scores[i]는 인덱스 i의 원소를 나타내며, 읽을 수도 있고 대입으로 바꿀 수도 있습니다. 첫 원소는 scores[0], 마지막 원소는 scores[4]입니다.
#include <cstddef>
#include <iostream>
#include <iterator>
int main() {
int scores[5]{85, 92, 78, 95, 88};
std::cout << scores[0] << ' ' << scores[2] << '\n';
scores[1] = 90;
for (std::size_t i = 0; i < std::size(scores); ++i) {
std::cout << scores[i] << ' ';
}
std::cout << '\n';
int sum{};
for (int score : scores) {
sum += score;
}
std::cout << "sum " << sum << '\n';
}85 78
85 90 78 95 88
sum 436scores[1] = 90은 원소 하나의 값만 바꿀 뿐 배열의 길이는 그대로 5입니다. 첫 순회는 C++17의 std::size(scores)로 원소 개수 5를 얻어 i < 5까지 돌고, 둘째 순회는 for 반복문에서 본 범위 기반 for로 끝을 적지 않고 모든 원소를 더합니다. 85 + 90 + 78 + 95 + 88은 436입니다. 인덱스가 필요하지 않다면 범위 기반 for가 더 단순하고, 원소를 바꾸려면 for (int& score : scores)처럼 참조로 받습니다.
범위 밖 접근
크기가 N인 배열의 올바른 인덱스는 0 이상 N 미만입니다. 내장 배열의 []는 이 범위를 검사하지 않으므로, 범위 밖 인덱스로 읽거나 쓰는 것은 정의되지 않은 동작(undefined behavior)입니다.
int data[3]{10, 20, 30};
int first = data[0]; // 올바름
int last = data[2]; // 올바름
// int wrong = data[3]; // 정의되지 않은 동작
// data[5] = 100; // 정의되지 않은 동작정의되지 않은 동작에는 컴파일 오류도, 예외도, 프로그램 종료도 보장되지 않습니다. 옆에 있는 다른 변수의 값을 조용히 바꾸고 아무 일 없다는 듯이 계속 실행될 수도 있어서 찾기 어려운 버그가 됩니다. GCC는 최적화를 켠 빌드에서 -Wall의 -Warray-bounds로 상수 인덱스의 일부 범위 밖 접근을 경고하지만, 모든 경우를 잡지는 못합니다.
반복문으로 배열을 다룰 때 가장 흔한 실수는 조건을 i <= N으로 써서 마지막에 data[N]을 읽는 것입니다. 0부터 세는 인덱스에는 i < N이 맞고, 끝을 직접 적을 필요가 없는 범위 기반 for를 쓰면 이 실수가 생기지 않습니다.
원소 개수와 배열이 포인터로 바뀌는 경우
배열 자체가 보이는 자리에서 sizeof(scores)는 배열 전체의 바이트 수이고, sizeof(scores[0])은 원소 하나의 바이트 수입니다. 그래서 둘을 나누면 원소 개수가 나옵니다.
#include <cstddef>
#include <iterator>
int scores[]{85, 92, 78, 95, 88};
constexpr std::size_t count = sizeof(scores) / sizeof(scores[0]);
static_assert(count == 5);
static_assert(std::size(scores) == 5);int가 몇 바이트인지는 구현마다 다를 수 있지만, 같은 원소의 바이트 수로 나누므로 결과는 언제나 5입니다. static_assert는 컴파일할 때 조건을 검사해 거짓이면 컴파일 오류를 내는 선언입니다. 새 코드에서는 뜻이 바로 드러나는 std::size(scores)를 씁니다.
주의할 점은 배열 이름이 대부분의 식에서 첫 원소를 가리키는 포인터로 바뀐다는 것입니다. 특히 함수의 매개변수를 배열처럼 적어도 실제 타입은 포인터가 되므로, 함수 안에서는 원래 배열의 길이를 알 수 없습니다.
#include <cstddef>
std::size_t inspect(const int values[5]) {
// values의 실제 타입은 const int*
return sizeof(values) / sizeof(values[0]);
}inspect 안의 sizeof(values)는 배열 전체가 아니라 포인터 하나의 바이트 수입니다. 포인터가 8바이트이고 int가 4바이트인 Windows x86-64의 GCC에서는 이 함수가 대괄호 안의 5와 상관없이 2를 돌려줍니다. 이 대괄호 안의 숫자는 무시되며, std::size(values)는 values가 배열이 아니므로 컴파일 오류입니다. 함수와 포인터는 함수의 정의와 호출과 포인터와 배열에서 자세히 다룹니다.
C++ · EXTENT AT THE BOUNDARY
함수 경계에서도 배열의 길이와 소유 관계를 보존한다
sizeof(array)는 실제 배열 객체가 보이는 자리에서만 전체 바이트 수입니다. 함수 매개변수의 T values[N]는 T*로 조정되므로, API 경계에서는 크기를 타입이나 값으로 함께 전달해야 합니다.
| 표현 | 크기·소유 | 접근·용도 |
|---|---|---|
| 내장 배열 | T[N]: 컴파일 시 고정된 N > 0개의 원소를 연속 소유하는 실제 배열 객체 |
std::size와 범위 for; 범위 밖 []는 undefined behavior; 단순한 지역 고정 저장과 저수준 인터페이스 |
| 고정 컨테이너 | std::array<T, N>: 컴파일 시 고정되고 N은 타입의 일부; 연속 원소를 소유하며 값처럼 복사·대입 가능 |
size(), 범위 for, at()의 범위 검사; 일반적인 고정 크기 값 |
| 가변 컨테이너 | std::vector<int>: 실행 중 크기를 결정·변경하며 int 원소를 연속 저장하고 소유 |
size(), 범위 for, at()의 범위 검사; 입력에 따라 길이가 달라지는 값 |
| 읽기 전용 뷰 | std::span<const T>: 뷰가 길이를 함께 보관하며 연속 원소를 소유하지 않음 |
size()와 범위 for; C++20 읽기 전용 함수 경계; 사용 중 원소와 저장 공간이 유효해야 함. 소유자가 살아 있어도 재할당 등으로 무효화될 수 있음 |
T[N]
고정된 N > 0개의 원소를 연속 소유합니다. 실제 배열 자리에서는 std::size와 범위 for를 쓸 수 있지만, 범위 밖 []는 undefined behavior입니다.
std::array<T, N>
고정 크기 연속 컨테이너입니다. 값처럼 복사·대입할 수 있고 size()와 at()을 제공하지만, N은 타입의 일부이며 크기를 바꿀 수 없습니다.
std::vector<int>
실행 중 크기를 정하고 바꿀 수 있는 소유 컨테이너입니다. int 원소는 연속 저장되며 size(), 범위 for, at()을 제공합니다.
std::span<const T>
C++20의 비소유 연속 뷰입니다. 길이를 함께 전달하지만 원본의 수명을 늘리지 않으므로 사용 동안 원소와 저장 공간이 유효해야 합니다. 소유자가 살아 있어도 재할당 등으로 뷰가 무효화될 수 있습니다.
실제 배열: 전체 바이트와 원소 수
int scores[]{85, 92, 78, 95, 88};
sizeof(scores)
== 5 * sizeof(scores[0]);
std::size(scores) == 5;
sizeof(int)가 4라는 보장은 없습니다. 비율은 원소 크기와 무관하게 실제 배열에서 5가 됩니다.
함수 경계: 포인터와 길이를 분리하지 않는다
void old_api(int values[5]);
// 매개변수 타입은 int*
void print(
std::span<const int> values) {
for (int value : values) {
use(value);
}
}
sizeof(values)는 old_api 안에서 포인터 크기입니다. std::span은 원소 주소와 길이를 한 뷰로 전달합니다.
선택 기준 고정 크기 값은 std::array, 실행 중 크기는 std::vector, 소유하지 않는 API 입력은 std::span이 의도를 더 직접적으로 드러냅니다.
표준 라이브러리의 대안
현대 C++에서는 위 그림처럼 크기가 언제 정해지는지, 원소를 누가 소유하는지를 타입으로 드러내는 편이 안전합니다.
| 타입 | 크기 | 소유 | 쓰임 |
|---|---|---|---|
std::array<T, N> | 컴파일할 때 고정 | 원소를 소유 | 고정 크기 데이터를 값처럼 복사, 대입 |
std::vector<T> | 실행 중에 정하고 바꿈 | 원소를 소유 | 입력에 따라 길이가 달라지는 데이터 |
std::span<T> | 길이를 함께 보관 | 소유하지 않음 | C++20, 함수에 연속된 원소를 넘길 때 |
세 타입 모두 size()로 원소 개수를 알 수 있어서, 함수에 넘겨도 길이를 잃지 않습니다. std::array와 std::vector의 []도 범위를 검사하지 않지만, at()은 인덱스가 size() 이상이면 std::out_of_range 예외를 던집니다.
#include <array>
#include <iostream>
#include <span>
#include <vector>
void print(std::span<const int> values) {
for (int value : values) {
std::cout << value << ' ';
}
std::cout << "(" << values.size() << ")\n";
}
int main() {
std::array<int, 5> fixed{85, 92, 78, 95, 88};
std::vector<int> dynamic{10, 20, 30};
print(fixed);
print(dynamic);
std::cout << fixed.at(4) << '\n';
}85 92 78 95 88 (5)
10 20 30 (3)
88print는 std::span<const int>로 원소를 받으므로 std::array와 std::vector를 모두 넘길 수 있고, 길이도 함께 전달됩니다. span은 원소를 복사하지 않고 원래의 저장 공간을 가리키므로, span을 쓰는 동안 원래 컨테이너가 살아 있어야 합니다. std::vector는 std::string 클래스의 문자열처럼 길이가 바뀌는 데이터를 다룰 때 자주 쓰며, 뒤의 장에서 자세히 봅니다.
더 깊이: []도 검사하게 만드는 빌드 설정
표준은 std::vector나 std::array의 []에 범위 밖 인덱스를 넘기는 것을 정의되지 않은 동작으로 두지만, 표준 라이브러리 구현들은 이런 전제 조건을 검사하는 빌드 설정을 따로 제공합니다. GCC의 libstdc++는 _GLIBCXX_ASSERTIONS 매크로를 정의하면 문자열의 범위 검사나 스마트 포인터의 널 검사 같은 전제 조건 검사를 켜며, GCC 15부터는 최적화하지 않는 빌드에서 이 검사가 기본으로 켜집니다. 최적화한 빌드에서도 쓰려면 -D_GLIBCXX_ASSERTIONS를 직접 주고, GNU/Linux에서는 GCC 14에 들어온 -fhardened가 이 매크로를 다른 보안 옵션과 함께 켭니다.
Clang과 함께 쓰는 libc++는 _LIBCPP_HARDENING_MODE 매크로로 검사 수준을 고릅니다. libc++의 강화 모드 문서에 따르면 fast 모드는 컨테이너 원소에 접근할 때 범위를 벗어나지 않는지처럼 상수 시간에 끝나는 보안상 중요한 검사만 켜서 배포 빌드에도 쓰도록 만들었고, extensive와 debug로 갈수록 검사가 늘어납니다. fast 모드에서 검사가 실패하면 메시지 없이 그 자리에서 프로그램을 멈춥니다.
C++26은 P3471로 이런 방식을 표준에 들였습니다. 표준은 강화된 구현(hardened implementation)이라는 개념을 정하고, 그런 구현에서는 std::span의 []나 컨테이너의 front() 같은 함수의 전제 조건을 어기면 정의되지 않은 동작 대신 위반으로 처리하게 했습니다. 어떤 빌드가 강화된 구현인지와 그것을 켜는 방법은 구현이 정합니다.
정리
- 내장 배열
T name[N]은 같은 타입T의 원소N개를 이어서 저장하며,N은 컴파일할 때 정해지는 0보다 큰 상수입니다. - 리스트 초기화는 0번 원소부터 값을 넣고, 남은 산술 원소는 0이 되며, 값이 원소보다 많으면 컴파일 오류입니다.
- 올바른 인덱스는 0부터
N - 1까지이고, 범위 밖 접근은 정의되지 않은 동작입니다. - 배열 자체가 보이는 자리에서는
std::size로 원소 개수를 얻지만, 함수 매개변수의 배열은 포인터가 되어 길이를 잃습니다. - 고정 크기는
std::array, 실행 중 크기는std::vector, 함수에 넘길 때는std::span으로 크기와 소유를 드러냅니다.
연습 문제
1. 부분 초기화와 합계
다음 프로그램의 출력은 무엇인가요? values[1] = 3;을 values[3] = 3;으로 바꾸면 어떻게 되나요?
#include <iostream>
#include <iterator>
int main() {
int values[4]{7, 9};
values[1] = 3;
int sum{};
for (int value : values) { sum += value; }
std::cout << std::size(values) << ' ' << sum << '\n';
}해설 보기
처음 원소는 7, 9, 0, 0이고 대입 뒤에는 7, 3, 0, 0이므로 4 10이 출력됩니다. values[3] = 3으로 바꾸면 7, 9, 0, 3이 되어 4 19입니다. 마지막 올바른 원소는 values[3]이며, values[4]는 배열에 없는 칸입니다.
2. 매개변수의 크기
array_parameter.cpp의 inspect에 원소가 5개인 배열을 넘기면, Windows x86-64의 GCC에서 무엇을 돌려주나요? 원소가 10개인 배열을 넘기면 달라지나요?
해설 보기
values는 포인터이므로 sizeof(values)는 8, sizeof(values[0])은 4이고, 어느 배열을 넘기든 2를 돌려줍니다. 매개변수의 대괄호 안 숫자는 무시되므로, 함수에 배열의 길이를 알려 주려면 길이를 따로 넘기거나 std::span을 씁니다.
3. 표준이 아닌 선언
다음 세 선언 가운데 표준 C++에서 올바르지 않은 것은 무엇인가요? int a[0];, int n = 5; int b[n];, int c[3]{1, 2, 3, 4};
해설 보기
셋 모두 표준 C++에서 올바르지 않습니다. 크기는 0보다 커야 하고, 크기는 상수식이어야 하며, 값이 원소보다 많으면 안 되기 때문입니다. 다만 GCC는 앞의 둘을 확장 기능으로 받아들이고 -Wpedantic을 켜야 경고하므로, 표준을 지키는지 확인하려면 -pedantic-errors로 컴파일합니다. 셋째는 GCC에서도 오류입니다.
다음 글 다차원 배열에서는 배열의 원소가 다시 배열인 2차원 배열로 표나 격자 같은 데이터를 다루는 방법을 봅니다.