배열 선언

배열 선언 (Array declaration)

C++에서 배열은 '같은 타입의 객체를 연속적으로 배치해 놓은 것'이에요. 단순해 보이지만 배열 포인터로의 붕괴(decay), 다차원 배열, 크기를 모르는 배열 같은 세부 규칙이 꽤 있어요. 이 페이지에서 배열 타입의 선언이 정확히 무엇을 뜻하고 어떤 규칙이 따르는지 정리해 볼게요.

출처: cppreference

본문

배열 선언은 배열 타입의 객체를 선언해요.

문법

배열 선언은, 그 선언자(declarator)가 다음 형태인 어떤 단순 선언이에요.

noptr-declarator [expr (optional)] attr (optional)

T a[N]; 형태의 선언은 a를, 타입 T의 객체 N개를 연속적으로 할당한 배열 객체로 선언해요. 배열의 요소는 0, …, N-1로 번호가 매겨지고, a[0], …, a[N-1]처럼 첨자 연산자 []로 접근할 수 있어요.

배열은 어떤 기본 타입(void 제외), 포인터, 멤버에 대한 포인터, 클래스, 열거형, 또는 크기를 아는 다른 배열(이 경우 다차원 배열이라 불러요)로부터 만들 수 있어요. 즉, 크기를 모르는 배열 타입을 제외한 객체 타입만이 배열 타입의 요소 타입이 될 수 있어요. 요소 타입이 미완성인 배열 타입도 미완성 타입이에요.

참조의 배열이나 함수의 배열은 없어요.

배열 타입에 cv 한정자(typedef나 템플릿 타입 조작을 통해)를 적용하면 그 한정자는 요소 타입에 적용돼요. 하지만 cv 한정된 타입의 요소를 가진 배열 타입은 그것과 같은 cv 한정을 가진 것으로 간주돼요.

// a와 b는 같은 const 한정 타입 "5개의 const char 배열"

typedef const char CC;
CC a[5] = {};

typedef char CA[5];
const CA b = {};

new[]-표현식과 함께 쓸 때 배열의 크기는 0일 수 있어요. 그런 배열에는 요소가 없어요.

int* p = new int[0]; // p[0]이나 *p에 접근하는 것은 undefined
delete[] p; // 정리는 여전히 필요

대입 (Assignment)

배열 타입의 객체는 통째로 수정할 수 없어요. lvalue이긴 하지만(예: 배열의 주소를 취할 수 있어요) 대입 연산자의 왼쪽 피연산자에 나타날 수는 없어요.

int a[3] = {1, 2, 3}, b[3] = {4, 5, 6};
int (*p)[3] = &a; // OK: a의 주소는 취할 수 있음
a = b;            // error: a는 배열

struct { int c[3]; } s1, s2 = {3, 4, 5};
s1 = s2; // OK: 암시적으로 정의된 복사 대입 연산자는
         // 배열 타입의 데이터 멤버를 대입할 수 있음

배열→포인터 붕괴 (Array-to-pointer decay)

배열 타입의 lvalue·rvalue에서 포인터 타입의 rvalue로의 암시적 변환이 있어요. 배열의 첫 번째 요소를 가리키는 포인터를 만드는 거예요. 이 변환은 배열이 기대되지 않고 포인터가 기대되는 문맥에 배열이 나타날 때마다 사용돼요.

#include <iostream>
#include <iterator>
#include <numeric>

void g(int (&a)[3])
{
    std::cout << a[0] << '\n';
}

void f(int* p)
{
    std::cout << *p << '\n';
}

int main()
{
    int a[3] = {1, 2, 3};
    int* p = a;

    std::cout << sizeof a << '\n'  // 배열의 크기를 출력
              << sizeof p << '\n'; // 포인터의 크기를 출력

    // 배열이 허용되고 포인터가 허용되지 않는 곳에는 배열만 쓸 수 있음
    g(a); // OK: 함수는 배열을 참조로 받음
//  g(p); // error

    for (int n : a)            // OK: 배열은 범위 기반 for에 쓸 수 있음
        std::cout << n << ' '; // 배열 요소를 출력
//  for (int n : p)            // error
//      std::cout << n << ' ';

    std::iota(std::begin(a), std::end(a), 7); // OK: begin/end는 배열을 받음
//  std::iota(std::begin(p), std::end(p), 7); // error

    // 포인터가 허용되고 배열이 허용되지 않는 곳에는 둘 다 쓸 수 있음:
    f(a); // OK: 함수는 포인터를 받음
    f(p); // OK: 함수는 포인터를 받음

    std::cout << *a << '\n' // 첫 요소를 출력
              << *p << '\n' // 같음
              << *(a + 1) << ' ' << a[1] << '\n'  // 둘째 요소를 출력
              << *(p + 1) << ' ' << p[1] << '\n'; // 같음
}

다차원 배열 (Multidimensional arrays)

배열의 요소 타입이 또 다른 배열이면 그 배열을 다차원이라고 해요.

// 각각 3개의 int를 가진 배열 2개
int a[2][3] = {{1, 2, 3},  // 2 × 3 행렬로 볼 수 있음
               {4, 5, 6}}; // 행 우선(row-major) 배치

배열→포인터 붕괴가 적용될 때 다차원 배열은 첫 번째 요소(예: 첫 행이나 첫 평면에 대한 포인터)에 대한 포인터로 변환돼요. 배열→포인터 붕괴는 한 번만 적용돼요.

int a[2];            // 2개의 int 배열
int* p1 = a;         // a는 a의 첫 요소에 대한 포인터로 붕괴

int b[2][3];         // 3개의 int 배열 2개
// int** p2 = b;     // error: b는 int**로 붕괴하지 않음
int (*p2)[3] = b;    // b는 b의 첫 3요소 행에 대한 포인터로 붕괴

int c[2][3][4];      // 4개의 int 배열 3개의 배열 2개
// int*** p3 = c;    // error: c는 int***로 붕괴하지 않음
int (*p3)[3][4] = c; // c는 c의 첫 3 × 4 요소 평면에 대한 포인터로 붕괴

크기를 모르는 배열 (Arrays of unknown bound)

배열 선언에서 expr을 생략하면 선언되는 타입은 "T의 크기를 모르는 배열"이고, 이는 미완성 타입의 일종이에요. 다만 집합체 초기화가 있는 선언에서는 예외예요.

extern int x[];      // x의 타입은 "int의 크기를 모르는 배열"
int a[] = {1, 2, 3}; // a의 타입은 "3개의 int 배열"

배열 요소는 크기를 모르는 배열일 수 없으므로, 다차원 배열은 첫 번째 차원 외에는 크기를 모를 수 없어요.

extern int a[][2]; // OK: 2개의 int 배열의 크기를 모르는 배열
extern int b[2][]; // error: 배열이 미완성 요소 타입을 가짐

같은 스코프에서 해당 엔티티의 앞선 선언에 크기가 지정돼 있었다면, 생략된 배열 크기는 그 앞선 선언과 같은 것으로 간주돼요. 클래스의 정적 데이터 멤버 정의에서도 마찬가지예요.

extern int x[10];
struct S
{
    static int y[10];
};

int x[];               // OK: 크기는 10
int S::y[];            // OK: 크기는 10

void f()
{
    extern int x[];
    int i = sizeof(x); // error: 미완성 객체 타입
}

크기를 모르는 배열에 대한 참조와 포인터는 만들 수 있지만, 크기를 아는 배열·배열 포인터로부터 초기화·대입될 수는 없었어요(C++20 이전) / 될 수 있어요(C++20부터). 참고로 C 언어에서는 크기를 모르는 배열에 대한 포인터가 크기를 아는 배열에 대한 포인터와 호환돼서 양방향으로 변환·대입 가능해요.

extern int a1[];

int (&r1)[] = a1;  // OK
int (*p1)[] = &a1; // OK
int (*q)[2] = &a1; // error (그러나 C에서는 OK)

int a2[] = {1, 2, 3};
int (&r2)[] = a2;  // OK (C++20부터)
int (*p2)[] = &a2; // OK (C++20부터)

크기를 모르는 배열에 대한 포인터는 포인터 산술에 참여할 수 없고 첨자 연산자 왼쪽에 쓸 수 없지만, 역참조는 할 수 있어요.

배열 rvalue

배열은 값으로 함수에서 반환될 수 없고 대부분의 캐스트 표현식의 대상이 될 수 없지만, 타입 별칭을 써서 중괄호 초기화 함수형 캐스트로 배열 임시 객체를 만들면 배열 prvalue를 만들 수 있어요.

배열 xvalue는 클래스 rvalue의 배열 멤버에 직접 접근하거나, std::move 또는 rvalue 참조를 반환하는 다른 캐스트·함수 호출을 써서 만들 수 있어요.

#include <iostream>
#include <type_traits>
#include <utility>

void f(int (&&x)[2][3])
{
    std::cout << sizeof x << '\n';
}

struct X
{
    int i[2][3];
} x;

template<typename T>
using identity = T;

int main()
{
    std::cout << sizeof X().i << '\n';           // 배열의 크기
    f(X().i);                                    // OK: xvalue에 바인딩
//  f(x.i);                                      // error: lvalue에 바인딩 불가

    int a[2][3];
    f(std::move(a));                             // OK: xvalue에 바인딩

    using arr_t = int[2][3];
    f(arr_t{});                                  // OK: prvalue에 바인딩
    f(identity<int[][3]>{{1, 2, 3}, {4, 5, 6}}); // OK: prvalue에 바인딩

}

출력:

24
24
24
24
24

더 알아보기

  • 배열의 메모리 배치와 요소 접근은 포인터·형변환 문서와 이어져요.
  • std::array 같은 표준 라이브러리 배열 컨테이너와도 비교해 보면 좋아요.
  • cppreference의 배열 선언 원문에서 결함 보고 기록을 더 볼 수 있어요.