C++ 배열이란? 선언과 초기화부터 포인터와의 관계까지

같은 타입의 데이터를 여러 개 저장해야 할 때 각 변수를 하나씩 선언하면 코드가 길어지고 관리하기도 어렵습니다.

int score1 = 100;
int score2 = 90;
int score3 = 80;

C++에서는 배열(Array)을 사용해 같은 타입의 여러 데이터를 하나의 이름으로 묶을 수 있습니다.

int scores[3] = { 100, 90, 80 };

배열에 저장된 각각의 값을 원소(Element)라고 하며, 인덱스(Index)를 사용해 원하는 원소에 접근할 수 있습니다.

이번 글에서는 배열의 선언과 초기화, 메모리 구조와 인덱스, 범위 초과 문제, 배열과 포인터의 관계, 배열 순회와 동적 배열까지 알아보겠습니다.


배열은 언제 사용할까?

같은 타입의 데이터를 여러 개 저장하고 싶을 때 배열을 사용할 수 있습니다.

예를 들어 50명의 점수를 저장하기 위해 변수 50개를 각각 선언하면 관리하기 어렵습니다.

int score1;
int score2;
int score3;
// ...
int score50;

배열을 사용하면 정수 50개를 하나의 이름으로 묶을 수 있습니다.

int scores[50] = {};

각 점수에는 배열의 인덱스를 사용해 접근합니다.

scores[0] = 100;
scores[1] = 90;
scores[49] = 80;

배열의 인덱스는 0부터 시작합니다. 따라서 원소가 50개인 배열의 마지막 인덱스는 49입니다.

원소 위치 인덱스 접근 방법
첫 번째 원소 0 scores[0]
두 번째 원소 1 scores[1]
마지막 원소 49 scores[49]

배열 선언하기

기본 배열을 선언할 때는 원소의 타입, 배열의 이름, 원소 개수를 작성합니다.

원소 타입 배열 이름[원소 개수];

정수 5개를 저장할 배열은 다음과 같이 선언합니다.

int numbers[5];

numbers 배열에는 int 타입의 원소 5개를 저장할 수 있습니다.

다른 타입의 배열도 같은 방식으로 선언할 수 있습니다.

float speeds[10];
char grades[3];
double positions[20];

기본 배열의 원소 타입은 모두 같아야 합니다. 하나의 int 배열에 정수와 문자열처럼 서로 다른 타입의 값을 함께 저장할 수는 없습니다.

기본 배열의 원소 개수는 컴파일 시점에 알 수 있는 상수 표현식이어야 합니다. 실행 중에 결정되는 크기가 필요하다면 동적 배열이나 std::vector를 사용할 수 있습니다.

constexpr int count = 5;
int numbers[count]; // 가능

int length = 5;
// int values[length]; // 표준 C++에서는 허용되지 않음

배열 초기화하기

초기화하지 않은 지역 배열의 기본 타입 원소는 불확정한 값을 가질 수 있습니다.

int numbers[5];

// 초기화 전에 numbers[0]을 읽으면 안 된다.

배열의 원소를 사용하기 전에는 값을 직접 저장하거나 배열을 초기화해야 합니다.

빈 중괄호를 사용하면 모든 원소를 0에 해당하는 값으로 초기화할 수 있습니다.

int numbers[5] = {};

일부 원소에 초기값을 작성하면 나머지 원소는 0으로 초기화됩니다.

int numbers[5] = { 10, 20 };

각 원소에는 다음 값이 저장됩니다.

원소
numbers[0] 10
numbers[1] 20
numbers[2] 0
numbers[3] 0
numbers[4] 0

초기값을 모두 작성하는 경우에는 원소 개수를 생략할 수도 있습니다.

int numbers[] = { 10, 20, 30 };

컴파일러는 초기값의 개수를 확인해 numbers를 원소 3개를 가진 배열로 결정합니다.


인덱스로 배열 원소에 접근하기

배열의 각 값은 원소(Element)라고 하며, 대괄호 안에 인덱스를 작성해 원하는 원소에 접근합니다.

#include <iostream>

int main()
{
    int example[5] = {};

    example[0] = 1;
    example[4] = 10;

    std::cout << example[0] << '\n';
    std::cout << example[4] << '\n';

    std::cin.get();
}

example[0]은 첫 번째 원소이고, example[4]는 다섯 번째이자 마지막 원소입니다.

실행 결과

1
10

인덱스로 지정한 배열 원소는 일반 변수처럼 값을 읽거나 변경할 수 있습니다.

example[2] = 20;

int copiedValue = example[2];
example[2] += 10;

배열의 메모리 구조

배열을 선언하면 모든 원소가 메모리의 연속된 공간에 배치됩니다.

int example[5] = {};

위 배열에는 int 원소 5개가 차례대로 저장됩니다.

인덱스 원소 첫 번째 원소와의 거리
0 example[0] 0 × sizeof(int)
1 example[1] 1 × sizeof(int)
2 example[2] 2 × sizeof(int)
3 example[3] 3 × sizeof(int)
4 example[4] 4 × sizeof(int)

sizeof(int)가 4인 환경이라면 각 원소의 시작 주소는 일반적으로 4바이트 간격으로 배치됩니다.

#include <iostream>

int main()
{
    int example[5] = {};

    for (int index = 0; index < 5; ++index)
    {
        std::cout << &example[index] << '\n';
    }
}

주소를 출력하면 배열 원소들이 연속된 메모리 공간에 배치된 것을 확인할 수 있습니다.

각 원소가 연속해서 배치되므로 배열은 인덱스를 이용해 원하는 원소의 위치를 빠르게 계산할 수 있습니다.


배열의 범위를 벗어나면 어떻게 될까?

배열을 사용할 때는 배열의 유효한 범위 안에서만 원소에 접근해야 합니다.

크기가 5인 배열의 유효한 인덱스는 0부터 4까지입니다.

int example[5] = {};

example[0] = 1;
example[4] = 10;

다음 접근은 모두 배열의 범위를 벗어납니다.

example[-1] = 3; // 잘못된 접근
example[5] = 20; // 잘못된 접근

example[-1]은 첫 번째 원소보다 앞의 메모리에 접근하고, example[5]는 마지막 원소 다음의 메모리에 접근합니다.

C++의 기본 배열은 인덱스 범위를 자동으로 검사하지 않습니다. 범위를 벗어난 접근을 하더라도 항상 컴파일 오류나 즉각적인 실행 오류가 발생하는 것은 아닙니다.

다른 변수가 사용하는 메모리를 훼손하거나, 잘못된 값이 출력되거나, 프로그램이 비정상적으로 종료될 수 있습니다. 때로는 겉으로 아무 문제가 없는 것처럼 보일 수도 있습니다.

이처럼 C++ 표준이 실행 결과를 보장하지 않는 상황을 정의되지 않은 동작(Undefined Behavior)이라고 합니다.

마지막 원소의 인덱스는 배열의 원소 개수보다 1 작습니다. 반복문을 작성할 때는 일반적으로 index < length 조건을 사용해야 합니다.

for (int index = 0; index < length; ++index)
{
    // 배열의 유효한 원소에 접근한다.
}

배열과 포인터의 관계

배열의 이름은 포인터 변수 그 자체는 아닙니다. 배열은 여러 원소를 포함하는 하나의 배열 타입입니다.

int example[5] = {};

위 코드에서 example의 실제 타입은 원소 5개로 이루어진 int 배열입니다.

하지만 배열 이름은 대부분의 표현식에서 첫 번째 원소를 가리키는 포인터로 변환됩니다. 이를 배열에서 포인터로의 변환 또는 배열의 감쇠(Array Decay)라고 합니다.

int* pointer = example;

위 코드는 다음과 같이 첫 번째 원소의 주소를 저장한 것과 같습니다.

int* pointer = &example[0];

따라서 다음 두 포인터에는 같은 주소가 저장됩니다.

int* firstPointer = example;
int* secondPointer = &example[0];

다만 배열과 포인터가 완전히 같은 것은 아닙니다.

int example[5] = {};
int* pointer = example;

std::cout << sizeof(example) << '\n';
std::cout << sizeof(pointer) << '\n';

sizeof(example)은 배열 전체의 크기를 반환하지만, sizeof(pointer)는 포인터 변수 하나의 크기를 반환합니다.

sizeof(int)가 4이고 포인터 크기가 8인 환경에서는 다음과 같이 출력될 수 있습니다.

20
8
대상 의미
example 원소 5개를 가진 실제 배열
pointer 배열의 첫 번째 원소 주소를 저장한 포인터 변수

포인터 연산으로 배열 원소에 접근하기

배열의 인덱스 연산은 포인터 연산과 밀접한 관계가 있습니다.

다음 두 표현식은 같은 배열 원소에 접근합니다.

example[index]
*(example + index)

example + index는 인덱스에 해당하는 원소의 주소를 계산하고, 앞의 *는 해당 주소를 역참조합니다.

#include <iostream>

int main()
{
    int example[5] = {};
    example[0] = 1;
    example[4] = 10;

    int* pointer = example;

    *(pointer + 2) = 20;

    std::cout << example[2] << '\n';
    std::cout << *(pointer + 2) << '\n';
    std::cout << pointer << '\n';

    std::cin.get();
}

pointer + 2는 주소를 단순히 2바이트 증가시키는 것이 아닙니다.

pointer의 타입이 int*이므로 int 원소 두 개만큼 이동합니다. sizeof(int)가 4라면 실제 주소는 일반적으로 8바이트 이동합니다.

실행 결과 예시

20
20
000000D5C8AFF7C8

마지막에 출력되는 메모리 주소는 프로그램을 실행할 때마다 달라질 수 있습니다.

배열 표현식 포인터 표현식
example[0] *(example + 0)
example[1] *(example + 1)
example[2] *(example + 2)

배열의 원소 개수 구하기

기본 배열 전체의 크기를 원소 하나의 크기로 나누면 원소 개수를 계산할 수 있습니다.

int example[5] = {};

int length = sizeof(example) / sizeof(example[0]);

sizeof(example)은 배열 전체의 바이트 크기를 반환하고, sizeof(example[0])은 원소 하나의 바이트 크기를 반환합니다.

sizeof(int)가 4인 환경이라면 다음과 같이 계산됩니다.

sizeof(example)    → 20
sizeof(example[0]) → 4
20 / 4             → 5

C++17부터는 std::size를 사용해 원소 개수를 더 직접적으로 구할 수도 있습니다.

#include <iterator>

int example[5] = {};

auto length = std::size(example);

std::size(example)은 배열의 원소 개수인 5를 반환합니다.

sizeof(array) / sizeof(array[0])std::size(array)는 대상이 실제 배열 타입으로 유지되는 위치에서 사용해야 합니다. 배열이 포인터로 변환된 뒤에는 포인터만으로 원소 개수를 알아낼 수 없습니다.


함수 매개변수와 배열

배열을 함수에 전달할 때 다음과 같이 매개변수를 배열 형태로 작성할 수 있습니다.

void PrintValues(int values[])
{
}

그러나 함수 매개변수에서 작성한 int values[]는 실제 배열 매개변수가 아니라 int*로 조정됩니다.

따라서 다음 두 함수 선언은 매개변수 타입의 관점에서 같은 의미입니다.

void PrintValues(int values[]);
void PrintValues(int* values);

함수 안에서 sizeof(values)를 사용하면 원본 배열 전체의 크기가 아니라 포인터의 크기가 반환됩니다.

void PrintValues(int values[])
{
    // 잘못된 배열 길이 계산
    int length = sizeof(values) / sizeof(values[0]);
}

포인터에는 배열의 원소 개수 정보가 포함되어 있지 않기 때문입니다.

기본 배열을 포인터로 전달할 때는 원소 개수를 별도의 매개변수로 함께 전달할 수 있습니다.

#include <iostream>

void PrintValues(const int* values, int length)
{
    for (int index = 0; index < length; ++index)
    {
        std::cout << values[index] << '\n';
    }
}

int main()
{
    int values[3] = { 10, 20, 30 };

    PrintValues(values, 3);
}

values를 읽기만 하는 함수이므로 매개변수를 const int*로 선언했습니다. 함수 안에서는 포인터를 통해 배열의 원소를 읽을 수 있지만 변경할 수는 없습니다.


for문으로 배열 순회하기

배열의 모든 원소를 차례대로 처리할 때는 주로 for문을 사용합니다.

#include <iostream>

int main()
{
    int example[5] = {};
    int length = sizeof(example) / sizeof(example[0]);

    for (int index = 0; index < length; ++index)
    {
        example[index] = 2;
    }

    for (int index = 0; index < length; ++index)
    {
        std::cout << example[index] << '\n';
    }

    std::cin.get();
}

첫 번째 반복문은 배열의 모든 원소에 2를 저장하고, 두 번째 반복문은 각 원소를 출력합니다.

실행 결과

2
2
2
2
2

반복문의 조건은 index < length로 작성해야 합니다.

for (int index = 0; index < length; ++index)

만약 index <= length로 작성하면 마지막 반복에서 example[length]에 접근하므로 배열의 범위를 벗어나게 됩니다.


범위 기반 for문으로 배열 순회하기

C++11부터는 범위 기반 for문을 사용해 인덱스를 직접 관리하지 않고 배열을 순회할 수 있습니다.

int example[5] = {};

for (int value : example)
{
    std::cout << value << '\n';
}

value에는 각 배열 원소의 값이 복사됩니다. 따라서 value를 변경해도 원본 배열의 원소는 변경되지 않습니다.

for (int value : example)
{
    value = 2; // 복사본만 변경된다.
}

원본 배열의 원소를 변경하려면 레퍼런스를 사용해야 합니다.

for (int& value : example)
{
    value = 2;
}

각 원소를 복사하지 않고 읽기만 하려면 const 레퍼런스를 사용할 수 있습니다.

for (const int& value : example)
{
    std::cout << value << '\n';
}
선언 동작
int value 각 원소의 값을 복사함
int& value 원본 원소를 참조하며 변경할 수 있음
const int& value 원본 원소를 읽기 전용으로 참조함

배열을 동적으로 할당하기

기본 배열의 크기는 일반적으로 컴파일 시점에 결정되어야 합니다.

프로그램 실행 중에 배열의 크기를 결정해야 한다면 new[]를 사용해 배열을 동적으로 할당할 수 있습니다.

int length = 5;
int* values = new int[length] {};

new int[length]int 원소 length개를 저장할 메모리를 할당하고 첫 번째 원소의 주소를 반환합니다.

뒤의 빈 중괄호를 작성하면 모든 정수 원소가 0으로 초기화됩니다.

#include <iostream>

int main()
{
    int length = 5;
    int* values = new int[length] {};

    for (int index = 0; index < length; ++index)
    {
        values[index] = 2;
    }

    for (int index = 0; index < length; ++index)
    {
        std::cout << values[index] << '\n';
    }

    delete[] values;
    values = nullptr;

    std::cin.get();
}

new[]로 할당한 배열은 사용이 끝난 뒤 반드시 delete[]로 해제해야 합니다.

delete[] values;
values = nullptr;

delete[]를 빠뜨리면 더 이상 사용하지 않는 메모리를 해제하지 못하는 메모리 누수(Memory Leak)가 발생할 수 있습니다.

메모리를 해제한 뒤 포인터에 nullptr를 저장하면 해당 포인터를 통해 해제된 메모리에 다시 접근하는 실수를 줄일 수 있습니다.

동적으로 할당한 배열도 포인터만으로는 원소 개수를 알 수 없습니다. 배열의 길이를 별도의 변수로 함께 관리해야 합니다.


std::array와 std::vector

기본 배열은 C++의 메모리 구조와 포인터를 이해하는 데 중요하지만, 원소 개수 확인이나 범위 관리가 불편할 수 있습니다.

실제 C++ 프로그램에서는 상황에 따라 std::arraystd::vector 같은 표준 컨테이너를 사용할 수 있습니다.

크기가 고정된 std::array

std::array는 기본 배열처럼 원소 개수가 고정되어 있지만, 원소 개수 확인과 반복자 등의 기능을 제공합니다.

#include <array>
#include <iostream>

int main()
{
    std::array<int, 5> values = {};

    std::cout << values.size() << '\n';
}

크기를 변경할 수 있는 std::vector

std::vector는 실행 중에 원소의 개수를 변경할 수 있는 동적 배열 형태의 컨테이너입니다.

#include <vector>

int main()
{
    int length = 5;
    std::vector<int> values(length, 0);

    values.push_back(10);
}

std::vector는 내부 메모리를 자동으로 관리하므로 직접 new[]delete[]를 호출할 필요가 없습니다.

구분 크기 메모리 관리
기본 배열 고정 자동 변수라면 범위를 벗어날 때 정리됨
std::array 고정 자동으로 관리됨
new[] 동적 배열 할당할 때 결정 delete[] 필요
std::vector 실행 중 변경 가능 자동으로 관리됨

이번 글의 기본 배열과 동적 할당 예제는 배열의 메모리 구조와 포인터의 동작을 이해하기 위한 내용입니다. 실제 프로젝트에서는 요구사항에 맞는 표준 컨테이너를 우선 고려하는 것이 좋습니다.


정리

  • 배열은 같은 타입의 원소 여러 개를 하나의 이름으로 묶어 저장합니다.
  • 배열을 선언할 때는 원소 타입과 배열 이름, 원소 개수를 작성합니다.
  • 배열의 각 값을 원소라고 하며 인덱스로 원하는 원소에 접근합니다.
  • 배열의 인덱스는 0부터 시작합니다.
  • 원소 개수가 N인 배열의 마지막 인덱스는 N - 1입니다.
  • 빈 중괄호로 배열을 초기화하면 기본 타입의 모든 원소를 0에 해당하는 값으로 초기화할 수 있습니다.
  • 초기화하지 않은 지역 배열의 기본 타입 원소를 읽으면 안 됩니다.
  • 배열의 원소는 메모리의 연속된 공간에 배치됩니다.
  • C++의 기본 배열은 인덱스 범위를 자동으로 검사하지 않습니다.
  • 배열 범위를 벗어나 접근하면 정의되지 않은 동작이 발생합니다.
  • 배열 이름은 대부분의 표현식에서 첫 번째 원소를 가리키는 포인터로 변환됩니다.
  • 배열은 포인터 그 자체가 아니며 sizeof 등의 문맥에서 차이가 나타납니다.
  • array[index]*(array + index)와 같은 원소에 접근합니다.
  • 포인터 연산은 포인터가 가리키는 원소 타입의 크기를 기준으로 이동합니다.
  • 실제 배열에서는 전체 크기를 원소 하나의 크기로 나누어 원소 개수를 구할 수 있습니다.
  • C++17부터는 std::size를 사용해 기본 배열의 원소 개수를 구할 수 있습니다.
  • 함수의 배열 매개변수는 포인터로 조정되므로 함수 안에서 sizeof로 원본 배열의 길이를 구할 수 없습니다.
  • 배열을 포인터로 함수에 전달할 때는 원소 개수를 별도의 매개변수로 함께 전달할 수 있습니다.
  • 일반 for문이나 범위 기반 for문을 사용해 배열을 순회할 수 있습니다.
  • 범위 기반 for문에서 원본 원소를 변경하려면 레퍼런스를 사용해야 합니다.
  • 실행 중에 배열의 크기를 결정해야 한다면 new[]로 동적 할당할 수 있습니다.
  • new[]로 할당한 배열은 반드시 delete[]로 해제해야 합니다.
  • 실제 C++ 프로젝트에서는 직접 동적 배열을 관리하기보다 std::arraystd::vector를 사용하는 경우가 많습니다.

이전 글: C++ 함수에서 포인터와 레퍼런스 사용하기

다음 글: C++ 문자열이란? 문자 배열과 포인터로 이해하는 C 스타일 문자열

댓글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다