포인터와 배열
배열 이름이 첫 요소 주소로 해석되는 원리와 포인터 산술을 익히고 배열 자체와 포인터 변수의 차이를 구분합니다.
지금까지 포인터가 메모리 주소를 저장하고 접근하는 도구임을 배웠고, 참조자는 변수의 별명으로 사용됨을 이해했습니다.
이번 절에서는 포인터의 대표적인 응용 분야인 배열(Array)과의 관계를 다룹니다.
C++에서 배열 이름은 특정 문맥에서 첫 번째 요소의 주소로 해석됩니다.
이러한 특성 때문에 포인터와 배열은 밀접하게 관련되어 있고 유사한 인덱싱 문법으로 사용될 수 있습니다. 다만 변환 방향은 대칭이 아닙니다. 배열 식이 특정 문맥에서 첫 요소 포인터로 변환될 뿐, 포인터가 배열 객체로 바뀌는 것은 아닙니다.
이 장에서는 배열 이름이 포인터처럼 동작하는 원리, 포인터 산술 연산, 배열과 포인터의 차이를 정리합니다.
배열 이름이 첫 요소 주소로 해석되는 흐름, 포인터 산술 이동, arr와 int* p의 차이를 한 장으로 먼저 정리합니다.
배열 식은 많은 문맥에서 첫 번째 요소 포인터로 변환된다
C++에서 배열의 이름은 특별한 의미를 가집니다.
배열 이름은 포인터 객체가 아닙니다. 배열 타입의 식은 sizeof의 피연산자이거나 단항 &의 피연산자인 경우 등을 제외한 많은 문맥에서 첫 번째 요소를 가리키는 포인터로 변환됩니다. 이를 배열-포인터 변환(array-to-pointer conversion) 이라고 합니다.
#include <iostream>
int main() {
int arr[5] = {10, 20, 30, 40, 50}; // 5개 int 요소를 가진 배열
// 배열 전체의 주소: 타입은 int (*)[5]
std::cout << "배열 arr의 주소 (&arr): " << &arr << std::endl;
// 배열의 첫 번째 요소 arr[0]의 주소
std::cout << "arr[0]의 주소 (&arr[0]): " << &arr[0] << std::endl;
// 이 식에서 arr은 첫 번째 요소를 가리키는 int*로 변환됨
std::cout << "첫 요소 포인터로 변환된 arr: " << arr << std::endl;
/* 예상 출력 (주소는 시스템마다 다름)
배열 arr의 주소 (&arr): 0x7ffee0000010
arr[0]의 주소 (&arr[0]): 0x7ffee0000010
첫 요소 포인터로 변환된 arr: 0x7ffee0000010
*/
return 0;
}위 예시의 세 표현은 같은 시작 위치를 출력할 수 있습니다. 하지만 변환된 arr와 &arr[0]의 타입은 int*이고, &arr의 타입은 배열 전체를 가리키는 int (*)[5]입니다.
따라서 주소 표기가 같다는 사실을 타입까지 같다는 뜻으로 해석하면 안 됩니다. arr + 1은 다음 int 요소로 이동하지만, &arr + 1은 배열 전체 한 개의 크기만큼 이동합니다.
이러한 특성 때문에, 배열 이름을 포인터 변수에 대입할 수 있습니다.
int arr[5] = {1, 2, 3, 4, 5};
int* p = arr; // 이 식에서 arr이 &arr[0]과 같은 int*로 변환됨
std::cout << *p << std::endl; // 출력: 1 (p가 가리키는 값)
std::cout << p[1] << std::endl; // 출력: 2 (포인터도 배열처럼 인덱스 접근 가능)포인터 산술 연산 (Pointer Arithmetic)
포인터에 정수를 더하거나 빼는 포인터 산술 연산은 배열 요소를 순회하는 데 매우 유용합니다.
일반적인 숫자 연산과 달리, 포인터 산술 연산은 포인터가 가리키는 데이터 타입의 크기를 고려하여 주소를 이동합니다.
포인터 + 정수: 포인터가 가리키는 주소에서(정수 * 데이터타입의 크기)만큼 메모리 주소를 증가시킵니다. 이는 배열의 다음 요소로 이동하는 것과 같습니다.포인터 - 정수: 포인터가 가리키는 주소에서(정수 * 데이터타입의 크기)만큼 메모리 주소를 감소시킵니다. 이는 배열의 이전 요소로 이동하는 것과 같습니다.포인터1 - 포인터2: 같은 배열 객체의 요소 또는 그 배열의 one-past 위치를 가리키는 두 포인터 사이의 거리를 요소 개수로 반환합니다.
포인터 산술과 관계 비교는 같은 배열 객체의 범위 및 one-past 위치를 전제로 합니다. one-past 포인터는 계산과 비교에는 사용할 수 있지만 역참조할 수 없으며, 산술 결과를 읽거나 쓸 때는 실제 배열 요소를 가리켜야 합니다.
#include <iostream>
int main() {
int numbers[] = {10, 20, 30, 40, 50};
int* p = numbers; // p는 numbers[0]의 주소를 가리킴
std::cout << "p가 가리키는 값: " << *p << std::endl; // 출력: 10
std::cout << "p의 주소: " << p << std::endl << std::endl;
p++; // p를 다음 int형 요소의 주소로 이동 (현재 주소 + sizeof(int) 만큼)
std::cout << "p++ 후:" << std::endl;
std::cout << "p가 가리키는 값: " << *p << std::endl; // 출력: 20
std::cout << "p의 주소: " << p << std::endl << std::endl; // 이전 주소 + sizeof(int)
p += 2; // p를 2칸 더 이동 (현재 주소 + 2 * sizeof(int) 만큼)
std::cout << "p += 2 후:" << std::endl;
std::cout << "p가 가리키는 값: " << *p << std::endl; // 출력: 40
std::cout << "p의 주소: " << p << std::endl << std::endl; // 이전 주소 + 2 * sizeof(int)
p -= 3; // p를 3칸 뒤로 이동 (현재 주소 - 3 * sizeof(int) 만큼)
std::cout << "p -= 3 후:" << std::endl;
std::cout << "p가 가리키는 값: " << *p << std::endl; // 출력: 10
std::cout << "p의 주소: " << p << std::endl << std::endl; // 원래 주소로 돌아옴
// 포인터 인덱싱 (배열 인덱싱과 동일)
std::cout << "포인터 인덱싱: " << p[2] << std::endl << std::endl; // 출력: 30 (p가 가리키는 주소 + 2 * sizeof(int) 위치의 값)
// 배열 순회 예시
std::cout << "포인터로 배열 순회:" << std::endl;
for (int* currentPtr = numbers; currentPtr < numbers + 5; ++currentPtr) {
std::cout << *currentPtr << " ";
}
std::cout << std::endl;
return 0;
}배열 이름과 포인터의 차이점 (중요!)
포인터와 배열은 저장 위치, 소유 범위, 접근 가능성을 기준으로 확인합니다.
비록 배열 이름이 포인터처럼 동작하지만, 몇 가지 중요한 차이점이 있습니다.
- 포인터 변수: 다른 주소를 가리키도록 변경할 수 있습니다. (
p = &arr[2];) - 배열 객체: 배열은 포인터 변수가 아니며 대입 가능한 객체가 아니므로,
arr = &arr[2];처럼 다른 주소를 저장할 수 없습니다.
sizeof 연산자- 포인터 변수: 포인터 변수 자체의 크기(예: 8바이트 for 64비트 시스템)를 반환합니다.
- 배열 이름: 배열 전체의 총 크기(
sizeof(요소타입) * 요소개수)를 반환합니다.
#include <iostream>
int main() {
int arr[5] = {1, 2, 3, 4, 5};
int* p = arr;
std::cout << "sizeof(arr): " << sizeof(arr) << " bytes" << std::endl; // 5 * sizeof(int)
std::cout << "sizeof(p): " << sizeof(p) << " bytes" << std::endl; // 포인터 객체의 크기
return 0;
}이 차이는 특히 배열을 함수에 인자로 전달할 때 중요합니다.
함수 매개변수 선언의 int a[]는 int* a로 조정됩니다. 따라서 그 함수 안의 sizeof(a)는 원래 배열 전체가 아니라 포인터 매개변수의 크기입니다. 이 형태만으로는 요소 개수나 소유권이 전달되지 않으므로, 함수가 읽을 범위와 변경 여부를 별도 계약으로 정해야 합니다.
- 포인터 변수: 다른 포인터 값을 대입할 수 있습니다. (
p1 = p2;) - 배열 객체: 내장 배열끼리는 직접 대입할 수 없습니다. 반복문이나 표준 알고리즘 등으로 요소를 복사해야 합니다. (
arr1 = arr2;는 오류)
배열과 포인터가 섞이는 코드를 읽을 때는 소유권, 길이 정보, 재지정 가능성을 분리해서 판단해야 합니다.
ARRAY OBJECT · CONTEXT · DECAY · RANGE
배열 이름은 포인터가 아니라, 문맥에 따라 포인터로 변환되는 배열 식이다
int arr[5]는 다섯 원소 배열 객체를 선언합니다. 같은 시작 주소여도 문맥에 따라 타입과 이동 단위가 달라집니다.
int arr[5] = {10, 20, 30, 40, 50};
arr의 선언 타입은 int[5]입니다. 배열은 포인터 값을 저장하는 변수가 아니며, 다른 주소를 대입할 수 없는 이유도 “상수 포인터”라서가 아니라 내장 배열이 대입 가능한 객체가 아니기 때문입니다.
이 문맥에서 배열 변환이 일어나는가?
연산자를 보기 전에 배열 이름을 무조건 int*로 읽지 않습니다. 먼저 array-to-pointer conversion이 적용되지 않는 문맥인지 판정합니다.
sizeof(arr)
배열 전체 타입을 유지하므로 결과는 5 × sizeof(int)입니다. 특정 구현에서 int가 4바이트라고 가정한 숫자를 언어 규칙으로 고정하지 않습니다.
&arr → int (*)[5]
배열 전체를 가리킵니다. &arr + 1의 이동 단위는 int[5] 하나이며, 배열 바로 뒤의 one-past 위치가 됩니다.
arr → int*
&arr[0]과 같은 첫 요소 포인터가 됩니다. 이때 arr + 1은 다음 int 요소를 가리킵니다.
| 표현 | 이 식에서의 타입 | 한 칸 이동 | 저장된 길이·재지정 |
|---|---|---|---|
arr | 일반 식에서 int*로 변환 | sizeof(int) | 변환된 포인터 값에는 길이가 없고, 배열 객체 자체에는 다른 주소를 대입할 수 없습니다. |
&arr[0] | int* | sizeof(int) | 첫 요소의 주소이며 배열 소유권을 새로 만들지 않습니다. |
&arr | int (*)[5] | int[5] 하나 | 배열 전체의 주소이며 출력되는 주소 표기는 첫 요소 주소와 같을 수 있습니다. |
p | 별도 int* 객체, arr로 초기화 | sizeof(int) | p는 재지정 가능하고 sizeof(p)는 포인터 객체의 크기입니다. |
arr → int*
일반 식에서는 첫 요소 포인터로 변환되고 한 칸은 int 한 요소입니다. 변환된 값에는 길이가 없으며 배열 객체 자체에는 다른 주소를 대입할 수 없습니다.
&arr[0] → int*
첫 요소의 주소입니다. 한 칸은 int 한 요소이며 배열 소유권은 새로 만들지 않습니다.
&arr → int (*)[5]
배열 전체의 주소입니다. 한 칸은 int[5] 하나이며 첫 요소 주소와 같은 위치로 표시될 수 있습니다.
int* p = arr
p는 재지정할 수 있는 별도 포인터 객체입니다. 한 칸은 int 한 요소이고 sizeof(p)는 포인터 객체의 크기입니다.
int a[] 매개변수의 타입은 int*
함수 안의 sizeof(a)는 원래 배열 전체가 아니라 포인터 매개변수의 크기입니다. 요소 개수, 읽기·쓰기 범위, 소유권은 이 구문에 들어 있지 않으므로 별도 인수와 API 계약으로 전달해야 합니다.
포인터 산술과 비교는 같은 배열의 요소 또는 one-past를 전제로 합니다. one-past는 계산할 수 있지만 역참조할 수 없고, 실제 원소에서만 읽고 씁니다.
C-스타일 문자열과 포인터
C-스타일 문자열은 널(\0) 문자로 끝나는 char 배열이므로, 당연히 포인터와 밀접한 관계를 가집니다.
char 포인터는 문자열을 다루는 데 자주 사용됩니다.
#include <iostream>
#include <cstring> // strlen, strcpy 등을 위해
int main() {
char greeting[] = "Hello World"; // char 배열이자 C-스타일 문자열
char* pChar = greeting; // char 포인터가 문자열 시작을 가리킴
std::cout << "greeting: " << greeting << std::endl; // 출력: Hello World
std::cout << "pChar: " << pChar << std::endl; // 출력: Hello World (cout은 char*를 문자열로 해석)
// 포인터 산술 연산으로 문자열 일부 접근
std::cout << "pChar + 6: " << (pChar + 6) << std::endl; // 출력: World
// strlen은 char*를 인자로 받음
std::cout << "문자열 길이: " << strlen(greeting) << std::endl; // 출력: 11
std::cout << "문자열 길이 (pChar로): " << strlen(pChar) << std::endl; // 출력: 11
// 포인터를 사용하여 문자열 요소 변경
pChar[0] = 'h';
std::cout << "변경된 문자열: " << greeting << std::endl; // 출력: hello World
return 0;
}std::cout은 char* 타입을 만나면 이를 메모리 주소가 아니라 널 종료된 C-스타일 문자열로 해석하여 출력하는 특별한 규칙을 가지고 있습니다. 이 동작과 strlen은 포인터가 읽을 수 있는 유효한 널 종료 문자 시퀀스를 가리킨다는 전제가 필요합니다.
다차원 배열과 포인터 (간략히)
2차원 배열은 배열의 배열이므로, 포인터로 다룰 때 더 복잡해집니다.
int matrix[2][3]은 두 개의 int[3] 배열로 구성된 것으로 볼 수 있습니다.
matrix자체의 타입은int[2][3]입니다. 많은 식에서 첫 번째 행을 가리키는int (*)[3]로 변환됩니다.matrix[0]자체의 타입은int[3]입니다. 많은 식에서 첫 번째int요소를 가리키는int*로 변환됩니다.
#include <iostream>
int main() {
int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}};
// 이 식에서 matrix는 첫 번째 행을 가리키는 int (*)[3]로 변환됨
std::cout << "matrix: " << matrix << std::endl; // 첫 번째 행 배열의 주소
std::cout << "matrix[0]: " << matrix[0] << std::endl; // 첫 번째 행 배열의 첫 번째 요소의 주소 (동일)
std::cout << "&matrix[0][0]: " << &matrix[0][0] << std::endl; // (동일)
// matrix[0]는 int* 타입처럼 동작
std::cout << "matrix[0][1]: " << matrix[0][1] << std::endl; // 2
std::cout << "*(matrix[0] + 1): " << *(matrix[0] + 1) << std::endl; // 2 (포인터 산술)
// 변환된 행 포인터의 산술은 다음 행으로 이동
std::cout << "(*(matrix + 1))[0]: " << (*(matrix + 1))[0] << std::endl; // 4 (두 번째 행의 첫 요소)
std::cout << "*(*(matrix + 1) + 0): " << *(*(matrix + 1) + 0) << std::endl; // 4 (더 명시적인 포인터 문법)
return 0;
}다차원 배열과 포인터의 관계는 초보자에게 혼란스러울 수 있으므로, 처음에는 간단한 1차원 배열과 포인터의 관계를 확실히 이해하는 데 집중하는 것이 좋습니다.
그래도 2차원 배열을 읽어야 할 때는 포인터가 한 칸 이동할 때의 단위가 int인지 int[3]인지부터 구분하면 됩니다.
2D ARRAY · ROW TYPE · ELEMENT TYPE · STRIDE
타입에 따라 행·열 이동 폭이 다르다
int matrix[2][3]는 int[3] 행이 둘인 배열입니다. 선언·포인터 타입을 나누면 행·열 이동 규칙이 선명합니다.
int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}};
int matrix[2][3]
첫 행은 1 · 2 · 3, 둘째 행은 4 · 5 · 6으로 초기화합니다.
matrix는 int[2][3], matrix[0]은 int[3] 타입입니다. 두 식은 많은 문맥에서 각각 첫 행 포인터와 첫 요소 포인터로 변환됩니다.
| 행 | 열 0 | 열 1 | 열 2 | 행 식의 타입 |
|---|---|---|---|---|
matrix[0] | matrix[0][0] = 1 | matrix[0][1] = 2 | matrix[0][2] = 3 | 배열 int[3], 일반 식에서 int* |
matrix[1] | matrix[1][0] = 4 | matrix[1][1] = 5 | matrix[1][2] = 6 | 배열 int[3], 일반 식에서 int* |
matrix[0]
열 0 · 1 · 2의 값은 차례로 1 · 2 · 3입니다. 일반 식에서는 int*로 변환됩니다.
matrix[1]
열 0 · 1 · 2의 값은 차례로 4 · 5 · 6입니다. 일반 식에서는 int*로 변환됩니다.
matrix → int (*)[3]
일반 식에서 첫 행을 가리키는 포인터로 변환됩니다. matrix에 1을 더하면 int[3] 한 행만큼 이동해 두 번째 행을 가리키고, 그 행의 첫 값은 4입니다.
matrix[0] → int*
일반 식에서 첫 요소 포인터가 됩니다. matrix[0] + 1은 int 한 원소만큼 이동해 값 2를 가리킵니다.
| 표현 | 변환 뒤 타입 | 이동 단위 | 역참조 결과 |
|---|---|---|---|
matrix + 1 | int (*)[3] | 행 하나, 즉 sizeof(int[3]) | *(matrix + 1)은 두 번째 행 배열입니다. |
matrix[0] + 1 | int* | 요소 하나, 즉 sizeof(int) | *(matrix[0] + 1)은 값 2입니다. |
matrix[r][c] | 최종 요소는 int | 먼저 행, 다음 열 | *(*(matrix + r) + c)와 같은 요소를 나타냅니다. |
matrix + 1
타입은 int (*)[3]이고 한 행만큼 이동합니다. 역참조하면 두 번째 행 배열입니다.
matrix[0] + 1
타입은 int*이고 한 요소만큼 이동합니다. 역참조한 값은 2입니다.
matrix[r][c]
행과 열을 차례로 선택한 int 요소입니다. *(*(matrix + r) + c)와 같습니다.
같은 주소여도 타입에 따라 이동 폭이 다릅니다. 선언 타입과 변환된 포인터 타입을 구분해 대상 한 단위 이동으로 읽습니다.
배열 이름이 포인터로 변환되는 순간과 배열 자체의 타입이 유지되는 순간을 나누어 보면, 포인터 산술과 sizeof 결과가 왜 달라지는지 더 쉽게 정리됩니다.