포인터와 배열
배열 이름이 첫 요소 주소로 해석되는 원리와 포인터 산술을 익히고 배열 자체와 포인터 변수의 차이를 구분합니다.
지금까지 포인터가 메모리 주소를 저장하고 접근하는 도구임을 배웠고, 참조자는 변수의 별명으로 사용됨을 이해했습니다.
이번 절에서는 포인터의 대표적인 응용 분야인 배열(Array)과의 관계를 다룹니다.
C++에서 배열 이름은 특정 문맥에서 첫 번째 요소의 주소로 해석됩니다.
이러한 특성 때문에 포인터와 배열은 매우 밀접하게 관련되어 있으며, 서로 변환 가능하고 유사한 문법으로 사용될 수 있습니다.
이 장에서는 배열 이름이 포인터처럼 동작하는 원리, 포인터 산술 연산, 배열과 포인터의 차이를 정리합니다.
배열 이름이 첫 요소 주소로 해석되는 흐름, 포인터 산술 이동, arr와 int* p의 차이를 한 장으로 먼저 정리합니다.
배열 이름은 첫 번째 요소의 주소이다
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): 0x7ffee0000010
arr[0]의 주소 (&arr[0]): 0x7ffee0000050
배열 이름 arr 자체의 값: 0x7ffee0000050
*/
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 << 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; // 이전 주소 + 4바이트 (64비트 시스템 가정)
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; // 이전 주소 + 8바이트
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) = 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차원 배열과 포인터의 관계를 확실히 이해하는 데 집중하는 것이 좋습니다.
그래도 2차원 배열을 읽어야 할 때는 포인터가 한 칸 이동할 때의 단위가 int인지 int[3]인지부터 구분하면 됩니다.
배열 이름이 포인터로 변환되는 순간과 배열 자체의 타입이 유지되는 순간을 나누어 보면, 포인터 산술과 sizeof 결과가 왜 달라지는지 더 쉽게 정리됩니다.