지난번에 포인터와 레퍼런스의 개념을 익혔고, 이제는 실전 코드로 제대로 다뤄보았다! 💪 포인터를 이용해 배열을 자유자재로 다루는 포인터 연산부터, 포인터 사용 시 가장 조심해야 할 메모리 접근 오류와 안전장치 nullptr까지 직접 코드를 짜며 경험해 보았다. 레퍼런스를 사용했을 때 코드가 얼마나 간결하고 안전해지는지도 체감할 수 있었다. 코드로 그 차이를 명확하게 정리했다.
*)와 주소 연산(&)을 코드로 구현할 수 있다.nullptr의 필요성을 안다.포인터의 가장 강력한 기능은 '간접적으로' 다른 변수의 값을 읽고 수정하는 것이다.
#include <iostream>
int main() {
int num = 10;
int* ptr = # // ptr은 num의 주소를 가리킴
std::cout << "변경 전 num의 값: " << num << std::endl;
// 포인터를 이용해 num의 값을 변경
*ptr = 20; // ptr이 가리키는 주소로 가서 값을 20으로 변경
std::cout << "변경 후 num의 값: " << num << std::endl;
std::cout << "ptr이 가리키는 값: " << *ptr << std::endl;
return 0;
}
// 출력 결과:
// 변경 전 num의 값: 10
// 변경 후 num의 값: 20
// ptr이 가리키는 값: 20
배열의 이름은 사실 그 배열의 첫 번째 요소의 주소를 가리키는 포인터 상수이다. 이 성질을 이용하면 포인터로 배열 요소를 순회할 수 있다.
ptr + 1의 의미: ptr의 주소값에 1을 더하는 게 아니라, ptr이 가리키는 타입의 크기만큼 주소를 이동시키는 것이다. int*라면 4바이트, char*라면 1바이트 뒤로 이동한다.#include <iostream>
int main() {
int arr[3] = {100, 200, 300};
int* ptr = arr; // 배열 이름 arr은 첫 번째 요소(&arr[0])의 주소와 같음
// 포인터 연산으로 배열 요소 접근
std::cout << "첫 번째 요소: " << *ptr << std::endl;
std::cout << "두 번째 요소: " << *(ptr + 1) << std::endl;
std::cout << "세 번째 요소: " << *(ptr + 2) << std::endl;
// 배열 인덱스 표현과 동일하게 동작함
std::cout << "ptr[1]의 값: " << ptr[1] << std::endl; // *(ptr + 1)과 같음
return 0;
}
// 출력 결과:
// 첫 번째 요소: 100
// 두 번째 요소: 200
// 세 번째 요소: 300
// ptr[1]의 값: 200
nullptr초기화되지 않은 포인터는 쓰레기 값을 주소로 가지기 때문에, 이를 역참조하면 프로그램이 비정상 종료될 수 있다. 이를 '댕글링 포인터' 문제라고도 한다. 따라서 포인터가 아무것도 가리키지 않을 때는 nullptr로 초기화하는 것이 안전하다.
#include <iostream>
int main() {
int* unsafe_ptr; // 초기화되지 않음. 위험!
// *unsafe_ptr = 10; // 실행 시 오류 발생 가능성이 매우 높음
int* safe_ptr = nullptr; // C++11부터는 nullptr 사용을 권장
if (safe_ptr == nullptr) {
std::cout << "포인터가 아무것도 가리키지 않습니다." << std::endl;
}
// 이후 유효한 주소를 할당하여 사용
int a = 42;
safe_ptr = &a;
if (safe_ptr != nullptr) {
std::cout << "이제 포인터는 값을 가리킵니다: " << *safe_ptr << std::endl;
}
return 0;
}
두 코드의 차이점을 느껴보면, 레퍼런스를 사용했을 때 코드가 훨씬 직관적이고 간결해진다는 것을 알 수 있었다.
#include <iostream>
// 값을 1 증가시키는 함수
// 1. 포인터 사용
void increment_ptr(int* p) {
if (p != nullptr) { // 항상 nullptr인지 체크하는 습관!
(*p)++; // 괄호 필수!
}
}
// 2. 레퍼런스 사용
void increment_ref(int& r) {
// nullptr 체크가 필요 없음. 레퍼런스는 항상 유효한 대상을 가리킴.
r++;
}
int main() {
int a = 5;
int b = 5;
increment_ptr(&a); // 주소를 넘겨줘야 함
increment_ref(b); // 변수 자체를 넘겨주면 됨
std::cout << "포인터로 증가시킨 값: " << a << std::endl;
std::cout << "레퍼런스로 증가시킨 값: " << b << std::endl;
return 0;
}
// 출력 결과:
// 포인터로 증가시킨 값: 6
// 레퍼런스로 증가시킨 값: 6
*ptr++라고 썼더니, 값이 증가하는 게 아니라 포인터의 주소가 다음으로 이동해 버렸다. 😭 값 자체를 증가시키려면 (*ptr)++ 처럼 괄호로 묶어 역참조를 먼저 수행해야 한다는 것을 깨달았다.void func(int val) 이렇게 말이다. C++ 함수는 기본적으로 '값에 의한 호출(Call by Value)'이라 복사본이 전달된다는 사실! 원본을 바꾸려면 포인터나 레퍼런스를 써야 했다.| 구분 | 포인터 사용법 (int* ptr = &val;) | 레퍼런스 사용법 (int& ref = val;) |
|---|---|---|
| 값 접근 | *ptr (역참조 연산자 * 필요) | ref (일반 변수처럼 바로 사용) |
| 값 변경 | *ptr = 20; | ref = 20; |
| 함수 전달 | func(&val); (주소를 전달) | func(val); (변수를 그대로 전달) |
| 안전성 | 항상 nullptr인지 확인하는 습관이 필요. | nullptr이 될 수 없어 상대적으로 안전함. |
| 주요 용도 | 동적 메모리 할당, 배열/이터레이터, 가리키는 대상이 바뀔 수 있을 때 | 함수 인자/반환 값, 객체 별명 등 단순하고 안전한 참조가 필요할 때 |