포인터와 배열
지금까지 포인터가 메모리 주소를 저장하고 접근하는 도구임을 배웠고, 참조자는 변수의 별명으로 사용됨을 이해했습니다.
이제 포인터의 가장 흔하고 강력한 응용 분야 중 하나인 배열(Array) 과의 관계를 깊이 있게 살펴보겠습니다.
C++에서 배열 이름은 특정 문맥에서 첫 번째 요소의 주소로 해석됩니다.
이러한 특성 때문에 포인터와 배열은 매우 밀접하게 관련되어 있으며, 서로 변환 가능하고 유사한 문법으로 사용될 수 있습니다.
이 장에서는 배열 이름이 포인터처럼 동작하는 원리, 포인터 산술 연산, 그리고 배열과 포인터의 차이점에 대해 상세히 알아보겠습니다.
배열 이름은 첫 번째 요소의 주소이다
C++에서 배열의 이름은 특별한 의미를 가집니다.
일반적으로 배열 이름은 배열의 첫 번째 요소의 메모리 주소를 나타내는 상수 포인터(constant pointer) 처럼 동작합니다.
#include <iostream>
int main() {
int arr[5] = {10, 20, 30, 40, 50}; // 5개 int 요소를 가진 배열
// 배열 이름 arr 자체의 주소 (배열 전체가 시작하는 주소)
std::cout << "배열 arr의 주소 (&arr): " << &arr << std::endl;
// 배열의 첫 번째 요소 arr[0]의 주소
std::cout << "arr[0]의 주소 (&arr[0]): " << &arr[0] << std::endl;
// 배열 이름 arr이 가리키는 주소 (첫 번째 요소의 주소와 동일)
std::cout << "배열 이름 arr 자체의 값: " << arr << std::endl;
/* 예상 출력 (주소는 시스템마다 다름):
배열 arr의 주소 (&arr): 0x7ffee0000000
arr[0]의 주소 (&arr[0]): 0x7ffee0000000
배열 이름 arr 자체의 값: 0x7ffee0000000
*/
return 0;
}
위 예시에서 볼 수 있듯이, arr
(배열 이름)과 &arr[0]
(첫 번째 요소의 주소)는 동일한 메모리 주소를 나타냅니다. 이는 배열 이름이 첫 번째 요소의 주소를 값으로 가지는 포인터처럼 행동한다는 것을 의미합니다.
이러한 특성 때문에, 배열 이름을 포인터 변수에 대입할 수 있습니다.
int arr[5] = {1, 2, 3, 4, 5};
int* p = arr; // arr은 arr[0]의 주소이므로, p는 arr[0]을 가리키게 됨
std::cout << *p << std::endl; // 출력: 1 (p가 가리키는 값)
std::cout << p[1] << std::endl; // 출력: 2 (포인터도 배열처럼 인덱스 접근 가능)
포인터 산술 연산 (Pointer Arithmetic)
포인터에 정수를 더하거나 빼는 포인터 산술 연산은 배열 요소를 순회하는 데 매우 유용합니다.
일반적인 숫자 연산과 달리, 포인터 산술 연산은 포인터가 가리키는 데이터 타입의 크기를 고려하여 주소를 이동합니다.
포인터 + 정수
: 포인터가 가리키는 주소에서(정수 * 데이터타입의 크기)
만큼 메모리 주소를 증가시킵니다. 이는 배열의 다음 요소로 이동하는 것과 같습니다.포인터 - 정수
: 포인터가 가리키는 주소에서(정수 * 데이터타입의 크기)
만큼 메모리 주소를 감소시킵니다. 이는 배열의 이전 요소로 이동하는 것과 같습니다.포인터1 - 포인터2
: 동일한 배열 내의 두 포인터 간의 거리를 (요소의 개수) 반환합니다.
#include <iostream>
int main() {
int numbers[] = {10, 20, 30, 40, 50}; // int 타입은 보통 4바이트
int* p = numbers; // p는 numbers[0]의 주소를 가리킴
std::cout << "p가 가리키는 값: " << *p << std::endl; // 출력: 10
std::cout << "p의 주소: " << p << std::endl;
p++; // p를 다음 int형 요소의 주소로 이동 (현재 주소 + sizeof(int) 만큼)
std::cout << "\np++ 후:" << std::endl;
std::cout << "p가 가리키는 값: " << *p << std::endl; // 출력: 20
std::cout << "p의 주소: " << p << std::endl; // 이전 주소 + 4바이트 (64비트 시스템 가정)
p += 2; // p를 2칸 더 이동 (현재 주소 + 2 * sizeof(int) 만큼)
std::cout << "\np += 2 후:" << std::endl;
std::cout << "p가 가리키는 값: " << *p << std::endl; // 출력: 40
std::cout << "p의 주소: " << p << std::endl; // 이전 주소 + 8바이트
p -= 3; // p를 3칸 뒤로 이동 (현재 주소 - 3 * sizeof(int) 만큼)
std::cout << "\np -= 3 후:" << std::endl;
std::cout << "p가 가리키는 값: " << *p << std::endl; // 출력: 10
std::cout << "p의 주소: " << p << std::endl; // 원래 주소로 돌아옴
// 포인터 인덱싱 (배열 인덱싱과 동일)
std::cout << "\n포인터 인덱싱: " << p[2] << std::endl; // 출력: 30 (p가 가리키는 주소 + 2 * sizeof(int) 위치의 값)
// 배열 순회 예시
std::cout << "\n포인터로 배열 순회:" << 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(요소타입) * 요소개수
)를 반환합니다.
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) = 20 (assuming int is 4 bytes) std::cout << "sizeof(p): " << sizeof(p) << " bytes" << std::endl; // 8 (on a 64-bit system) return 0; }
이 차이는 특히 배열을 함수에 인자로 전달할 때 중요합니다. 배열을 함수에 전달하면 실제로는 배열의 첫 번째 요소에 대한 포인터가 전달되기 때문에, 함수 내에서
sizeof
연산자를 사용하면 배열 전체의 크기가 아니라 포인터의 크기가 반환됩니다. -
대입 연산자
- 포인터 변수: 다른 포인터 값을 대입할 수 있습니다. (
p1 = p2;
) - 배열 이름: 배열을 다른 배열에 직접 대입할 수 없습니다. 요소별로 복사해야 합니다. (
arr1 = arr2;
는 오류)
- 포인터 변수: 다른 포인터 값을 대입할 수 있습니다. (
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-스타일 문자열로 해석하여 출력하는 특별한 규칙을 가지고 있습니다.
다차원 배열과 포인터 (간략히)
2차원 배열은 '배열의 배열'이므로, 포인터로 다룰 때 더 복잡해집니다.
int matrix[2][3]
은 두 개의 int[3]
배열로 구성된 것으로 볼 수 있습니다.
matrix
:int[3]
배열을 가리키는 포인터. (정확히는int(*)[3]
타입)matrix[0]
:int
요소를 가리키는 포인터 (int*
).
#include <iostream>
int main() {
int matrix[2][3] = {{1, 2, 3}, {4, 5, 6}};
// matrix는 첫 번째 행(배열)의 주소
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 (포인터 산술)
// matrix 자체에 대한 포인터 산술은 다음 행으로 이동
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차원 배열과 포인터의 관계를 확실히 이해하는 데 집중하는 것이 좋습니다.