같은 타입의 데이터를 여러 개 저장해야 할 때 각 변수를 하나씩 선언하면 코드가 길어지고 관리하기도 어렵습니다.
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::array나 std::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::array나std::vector를 사용하는 경우가 많습니다.
이전 글: C++ 함수에서 포인터와 레퍼런스 사용하기