본문 바로가기

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 변수를 쓸 수 있습니다.

array_declare.cpp
#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번 원소부터 차례로 들어갑니다. 아래 선언들은 값을 모두 주는 경우, 크기를 생략하는 경우, 값을 일부만 주는 경우, 빈 중괄호를 쓰는 경우를 차례로 보여 주며, 값을 일부만 주거나 하나도 주지 않은 배열이 어떻게 채워지는지는 줄 끝의 주석에 적어 두었습니다. 주석으로 막아 둔 마지막 줄은 컴파일되지 않는 예입니다.

array_init.cpp
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++ 1차원 배열이 같은 타입의 원소를 0부터 시작하는 연속된 칸에 저장하고, 리스트 초기화는 앞에서부터 값을 배치하며 남은 산술 원소를 0으로 초기화하고, 유효 인덱스와 순회 종료 조건을 지켜야 한다는 계약표

C++ · BUILT-IN ARRAY OBJECT

배열은 0부터 N − 1까지의 연속 원소를 다룬다

int scores[5]는 같은 타입의 원소 5개를 연속해서 소유합니다. 원소 번호는 0부터 4까지이며, 마지막 유효 인덱스는 N - 1입니다.

배열 안에 인덱스0부터4까지 다섯 원소가 연속한다두 배열은 서로 다른 객체를 비교한 것이다. scores의 원소값은85,92,78,95,88이고 partial은85,92를 명시하고 나머지0,0,0으로 초기화한다. 각 큰 영역 안의 붙어 있는 다섯 원소 영역은 같은 배열의 연속된 하위 객체다. 행 사이 화살표나 값 복사 관계는 없다. 위쪽 숫자는 인덱스이고 아래쪽 숫자는 저장 값이다.scores[5] · 전체 초기화085192278395488위 숫자: 인덱스 · 아래: 값partial[5] · 부분 초기화085192203040위 숫자: 인덱스 · 아래: 값두 배열은 별개의 초기화 사례
배열 안에 인덱스0부터4까지 다섯 원소가 연속한다두 배열은 서로 다른 객체를 비교한 것이다. scores의 원소값은85,92,78,95,88이고 partial은85,92를 명시하고 나머지0,0,0으로 초기화한다. 각 큰 영역 안의 붙어 있는 다섯 원소 영역은 같은 배열의 연속된 하위 객체다. 행 사이 화살표나 값 복사 관계는 없다. 위쪽 숫자는 인덱스이고 아래쪽 숫자는 저장 값이다.scores[5] · 전체 초기화085192278395488위 숫자: 인덱스 · 아래: 값partial[5] · 부분 초기화085192203040위 숫자: 인덱스 · 아래: 값두 배열은 별개의 초기화 사례
전체 초기화와 부분 초기화가 인덱스 0부터 배치되는 결과
선언 [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]입니다.

array_access.cpp
#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 436

scores[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)입니다.

out_of_range.cpp
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])은 원소 하나의 바이트 수입니다. 그래서 둘을 나누면 원소 개수가 나옵니다.

array_count.cpp
#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)를 씁니다.

주의할 점은 배열 이름이 대부분의 식에서 첫 원소를 가리키는 포인터로 바뀐다는 것입니다. 특히 함수의 매개변수를 배열처럼 적어도 실제 타입은 포인터가 되므로, 함수 안에서는 원래 배열의 길이를 알 수 없습니다.

array_parameter.cpp
#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++ 배열에서는 sizeof와 std::size가 전체 범위를 알지만 배열 매개변수는 포인터로 조정되어 길이를 잃으며, 고정 크기 값은 std::array, 실행 중 크기는 std::vector, 소유하지 않는 함수 경계는 std::span으로 표현하는 선택표

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 예외를 던집니다.

modern_arrays.cpp
#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)
88

print는 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;으로 바꾸면 어떻게 되나요?

partial_sum.cpp
#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차원 배열로 표나 격자 같은 데이터를 다루는 방법을 봅니다.