본문으로 건너뛰기

안동민 개발노트

본문 시작

1차원 배열

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

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

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

배열 객체의 세 가지 약속

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

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

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

선언과 배열 크기

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

Nstd::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}; // 오류: 원소보다 초기값이 많음

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

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

C++ · BUILT-IN ARRAY OBJECT

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

전체 초기화와 부분 초기화가 인덱스 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 < NN - 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)처럼 참조로 받습니다.

범위 밖 접근은 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::arraystd::vectoroperator[]도 유효한 인덱스를 요구합니다. 범위 오류를 예외로 처리하려면 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으로 의도를 더 명확히 표현합니다.