본문 바로가기

1차원 배열

C++ 내장 1차원 배열의 선언·초기화·인덱스·순회·크기 정보 경계를 익히고 std::array, std::vector, std::span과 선택 기준을 비교합니다.

여러 점수처럼 같은 타입의 값을 정해진 개수만큼 한 묶음으로 다룰 때 1차원 배열을 사용할 수 있습니다.

C++ 내장 배열은 단순하지만 계약이 엄격합니다. 크기는 컴파일 시 정해지고, 원소는 연속 저장되며, 인덱스 범위는 프로그래머가 지켜야 합니다.

배열 객체의 세 가지 약속

내장 배열 T name[N]은 다음 성질을 가집니다.

  • 모든 원소의 타입은 T로 같습니다.
  • N개 원소가 메모리에 연속해서 저장됩니다.
  • 원소 번호는 0부터 N - 1까지입니다.

연속 저장은 원소 사이의 주소 관계와 임의 위치 접근을 가능하게 합니다. 특정 성능 수치나 자동 경계 검사를 보장한다는 뜻은 아닙니다.

선언과 배열 크기

1차원 배열 선언 형식
T name[N];

N은 std::size_t로 변환되는 상수 표현식이어야 하며 0보다 커야 합니다. constexpr 변수는 사용할 수 있지만 실행 중에 정해지는 일반 변수는 표준 내장 배열의 크기로 사용할 수 없습니다.

컴파일 시 정해지는 크기
#include <cstddef>

constexpr std::size_t student_count = 5;

int scores[student_count]{};
double temperatures[30]{};
char grades[10]{};

표준 C++에는 가변 길이 배열(VLA)이 없습니다. 일부 컴파일러 확장에 의존하지 말고 실행 중 길이가 정해진다면 std::vector를 사용합니다.

자동 저장 기간의 int raw[5];처럼 스칼라 배열을 초깃값 없이 선언하면 원소 값이 설정되지 않습니다. 값을 쓰기 전에 읽지 않도록 하고, 기본적으로 int raw[5]{};처럼 초기화하는 편이 안전합니다.

리스트 초기화

중괄호는 첫 원소부터 차례대로 값을 배치합니다.

배열 초기화 규칙
int full[5]{85, 92, 78, 95, 88};
int inferred[]{1, 2, 3, 4, 5}; // 초기값 5개로 N을 추론

int partial[5]{85, 92}; // {85, 92, 0, 0, 0}
int zeros1[5]{0};       // {0, 0, 0, 0, 0}
int zeros2[5]{};        // {0, 0, 0, 0, 0}

// int too_many[2]{1, 2, 3}; // 오류: 원소보다 초기값이 많음

초깃값이 부족하면 나머지 원소는 빈 리스트에서 초기화된 것처럼 처리됩니다. int와 double 같은 산술 타입의 나머지 원소는 0이 됩니다. 빈 {}는 모든 원소를 값 초기화(value-initialize)하므로 산술 원소가 모두 0이 됩니다. 반대로 초깃값이 배열 크기보다 많으면 프로그램은 ill-formed입니다.

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]를 평가해 원소처럼 읽거나 쓰면 범위 밖 접근이며 동작이 정의되지 않습니다.

경계 검사 없음 연속 저장은 주소 관계를 보장하지만 성능 수치나 자동 범위 검사를 보장하지는 않습니다.

요소 읽기, 쓰기, 순회

name[index]는 한 원소를 가리킵니다. 첫 원소는 [0], 마지막 원소는 [N - 1]입니다.

읽기·쓰기와 두 가지 순회
#include <cstddef>
#include <iostream>
#include <iterator>

int main() {
    int scores[5]{85, 92, 78, 95, 88};

    std::cout << scores[0] << '\n'; // 85
    std::cout << scores[2] << '\n'; // 78

    scores[1] = 90;

    for (std::size_t i = 0; i < std::size(scores); ++i) {
        std::cout << scores[i] << ' ';
    }
    std::cout << '\n'; // 85 90 78 95 88

    for (const int score : scores) {
        std::cout << score << ' ';
    }
}

인덱스가 꼭 필요하지 않다면 범위 기반 for가 경계를 직접 적지 않아도 되어 단순합니다. 원소를 바꾸려면 for (int& score : scores)처럼 참조로 받습니다.

한 배열을 끝까지 따라가기

위 프로그램은 처음에 0번 칸의 값 85와 2번 칸의 값 78을 한 줄씩 출력합니다. scores[1]=90은 원소 하나만 바꾸므로 두 순회는 모두 85 90 78 95 88을 출력합니다. 배열의 길이는 5 그대로입니다. 원소 하나의 값을 바꾸는 것과 배열에 원소를 추가하는 것은 다른 작업입니다.

아래는 부분 초기화와 한 칸 쓰기를 더 작은 배열에서 재현합니다. 주석의 결과를 보기 전에 네 칸의 값을 적어 보세요.

부분 초기화 뒤 한 원소를 바꾸고 합하기
#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]이며, values[4]는 합에 포함할 다음 칸이 아닙니다.

범위와 값을 따로 확인하기

대입 대상만 values[3]으로 바꾸면 출력은 무엇인가요? 함수의 매개변수가 const int values[4]일 때 sizeof(values)/sizeof(values[0])가 항상 4인지도 아래 배열-포인터 설명을 읽고 판단합니다.

원소 상태와 함수 경계 풀이

대입 뒤에는 7·9·0·3이므로 4 19입니다. 함수의 대괄호 표기는 포인터 매개변수로 조정되어 원래 길이를 보존하지 않습니다. 해당 비율은 포인터 크기와 int 크기의 비율이므로 4를 보장하지 않습니다. 포인터가 원소를 가리킨다는 사실만으로 배열의 마지막 인덱스를 복원할 수는 없습니다.

범위 밖 접근은 undefined behavior

내장 배열의 operator[]는 범위를 검사하지 않습니다. 크기가 N이면 유효한 인덱스는 0 <= i && i < N입니다.

평가하면 안 되는 범위 밖 접근
int data[3]{10, 20, 30};

int first = data[0]; // 유효
int last = data[2];  // 유효

// int wrong = data[3]; // 평가하면 undefined behavior
// data[5] = 100;       // 평가하면 undefined behavior

data + 3이라는 한 칸 뒤 포인터 자체는 만들 수 있지만 그 위치에는 원소가 없으므로 역참조할 수 없습니다. 범위 밖 접근에는 컴파일 오류, 예외, 충돌 중 어느 것도 보장되지 않습니다.

sizeof, std::size, 배열-포인터 변환

실제 배열 객체가 보이는 자리에서 sizeof(array)는 전체 배열이 차지하는 바이트 수입니다. sizeof(array[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); // C++17

sizeof(int)가 4바이트라는 보장은 없습니다. 위 계산은 전체 바이트를 같은 원소 한 개의 바이트로 나누므로 실제 배열에서는 구현과 관계없이 5가 됩니다. 새 코드에서는 의도가 직접 드러나는 std::size(array)를 우선할 수 있습니다.

배열 이름은 대부분의 표현식에서 첫 원소를 가리키는 포인터로 변환됩니다. 특히 함수 매개변수에 배열처럼 적은 타입은 포인터 타입으로 조정됩니다.

배열처럼 보이는 매개변수는 포인터
void inspect(const int values[5]) {
    // values의 실제 매개변수 타입은 const int*
    const auto pointer_bytes = sizeof(values);

    // std::size(values); // 오류: values는 배열이 아님
}

inspect 안의 sizeof(values)는 원래 배열 전체가 아니라 포인터의 바이트 수입니다. 따라서 배열과 원소의 sizeof 비율로 호출자의 길이를 복원할 수 없습니다.

실제 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>: 컴파일 시 고정 크기이고 연속 저장합니다. 값처럼 복사·대입할 수 있으며 size()와 at()을 제공합니다. N은 타입의 일부이고 크기를 바꿀 수 없습니다.
  • std::vector<T>: 실행 중 길이를 정하고 바꿀 수 있는 소유 컨테이너입니다.
  • std::span<T>: C++20의 비소유 연속 뷰입니다. 함수 경계에서 주소와 길이를 함께 받지만 원본의 수명을 늘리지는 않습니다.

std::array와 std::vector의 operator[]도 유효한 인덱스를 요구합니다. 범위 오류를 예외로 처리하려면 at()을 사용하며, 인덱스가 size() 이상이면 std::out_of_range를 던집니다.

고정·가변 소유자와 비소유 함수 경계 (C++20)
#include <array>
#include <iostream>
#include <span>
#include <vector>

void print(std::span<const int> values) {
    for (const int value : values) {
        std::cout << value << ' ';
    }
    std::cout << '\n';
}

int main() {
    std::array<int, 5> fixed{85, 92, 78, 95, 88};
    std::vector<int> dynamic{10, 20, 30};

    print(fixed);
    print(dynamic);

    const int last = fixed.at(4);
}

고정 크기 데이터를 일반적인 값처럼 다루면 std::array, 입력에 따라 길이가 바뀌면 std::vector, 이미 소유된 연속 데이터를 함수가 잠시 보기만 하면 std::span이 자연스럽습니다.

핵심 정리

  • 내장 배열의 크기는 0보다 큰 컴파일 타임 상수이며 선언 뒤 바뀌지 않습니다.
  • 부분 리스트 초기화에서 남은 산술 원소는 0이 되고, 초깃값이 너무 많으면 ill-formed입니다.
  • 원소는 연속 저장되고 인덱스는 0부터 N - 1까지입니다.
  • 범위 밖 원소 접근은 undefined behavior이며 자동 진단이나 예외를 기대할 수 없습니다.
  • 실제 배열에서는 std::size와 범위 기반 for를 사용하고, 포인터로 조정되는 API 경계에서는 크기 정보를 별도로 보존합니다.
  • 목적에 따라 std::array, std::vector, std::span으로 의도를 더 명확히 표현합니다.