본문 바로가기
ZAION/C++

[C++]Lec04-01. Pointer & Reference

by 우기37 2024. 4. 26.

## 목차

1. Pointer

 

 

 

#1 Pointer

1 - 1) 포인터 개요

포인터 변수는 변수의 타입 중 하나이다. 포인터 변수의 값은 메모리의 주소를 저장하는 값이다. 지금싸지 사용한 변수와 포인터 변수와의 차이는 값을 저장하는지, 주소를 저장하는지에 대한 차이가 있다.

 

포인터는 가리키는 주소에 저장된 데이터의 타입을 알아야 한다.

 

포인터를 사용하는 이유

- 동적 할당을 통해 힙 영역의 메모리를 사용한다.

- 변수의 범위 문제로 접근할 수 없는 곳의 데이터를 사용(참조자와 유사한 목적)

- 배열의 효율적인 사용

- 다형성은 포인터를 기반으로 구현된다.

- 시스템 응용프로그램 / 임베디드 프로그래밍에서 메모리에 직접적인 접근이 필요하다.

 

 

 

1 - 2) 포인터의 정의

기존 변수 타입 뒤에 "*" 를 붙여 포인터 변수를 정의한다.

 - "int*"까지를 타입으로 생각하면 된다.

 

포인터의 정의 및 초기화

 

 

 

1 - 3) 주소로의 접근

변수의 주소값 얻어오기

- 포인터 변수는 주소값을 저장하므로, 주소값을 얻어올 수 있어야 한다.

- 이를 위해 주소 연산자 "&"를 사용한다.

 - 연산자가 적용되는 피연산자의 주소값이 반환된다.

 - 피연산자는 주소값을 얻을 수 있는 종류여야 한다.(l-value)

 

#include <iostream>
using namespace std;

int main() {
	int num = 10;

	cout << "Value : " << num << endl;	// 10
	cout << "Address : " << &num << endl;	// 0x00F3FBCC
	//cout << "Address : " << &10 << endl;	// Error! 10은 주소가 없다

}

 

 

#include <iostream>
using namespace std;

int main() {
	int* p;

	//cout << "Value : " << p << endl;	// garbage value
	cout << "Address : " << &p << endl;
	cout << "Size : " << sizeof(p) << endl;

	p = nullptr;

	cout << "Value : " << p << endl;

}

 

 

주소값의 이해

- "포인터 변수의 크기"와 "포인터가 가리키고 있는 대상의 크기"는 별개이다.

 

- 포인터 변수들은 모두 같은 크기이다.

 - x86에서는 4바이트

 - 포인터는 "주소값"을 저장하기 때문이다.

 

- 해당 주소의 값에 접근할 때 몇 바이트 크기인지 알아야 하기 때문에 타입이 필요하다.

 

#include <iostream>
#include <vector>
using namespace std;

int main() {
	int* p1 = nullptr;
	double* p2 = nullptr;
	unsigned long long* p3 = nullptr;
	vector<string>* p4 = nullptr;
	string* p5 = nullptr;

	cout << "p1 : " << p1 << endl;
	cout << "p2 : " << p2 << endl;
	cout << "p3 : " << p3 << endl;
	cout << "p4 : " << p4 << endl;
	cout << "p5 : " << p5 << endl;

}

 

- 주소가 저장된 변수의 메모리 크기는 모두 다르지만 포인터 변수가 저장하는 주소값을 모두 4바이트 크기로 동일하다.

 

#include <iostream>
#include <vector>
using namespace std;

int main() {
	int* p1 = nullptr;
	double* p2 = nullptr;
	unsigned long long* p3 = nullptr;
	vector<string>* p4 = nullptr;
	string* p5 = nullptr;

	cout << "p1 : " << sizeof(p1) << endl;
	cout << "p2 : " << sizeof(p2) << endl;
	cout << "p3 : " << sizeof(p3) << endl;
	cout << "p4 : " << sizeof(p4) << endl;
	cout << "p5 : " << sizeof(p5) << endl;

}

 

#include <iostream>
#include <vector>
using namespace std;

int main() {
	int* p1 = nullptr;
	double* p2 = nullptr;
	unsigned long long* p3 = nullptr;
	vector<string>* p4 = nullptr;
	string* p5 = nullptr;

	cout << "p1 : " << &p1 << endl;
	cout << "p2 : " << &p2 << endl;
	cout << "p3 : " << &p3 << endl;
	cout << "p4 : " << &p4 << endl;
	cout << "p5 : " << &p5 << endl;

}

 

포인터의 타입

컴파일러는 포인터가 가리키는 타입이 맞는지 확인한다.

 - int*는 int가 저장되지만, double*은 double이 저장된 주소만 가리킬 수 있다.

 

 

 

 

1 - 4) 역참조

포인터의 역참조

- 포인터의 주소에 저장된 데이터에 접근

- * 연산자를 사용한다.

 

예제1)

#include <iostream>
using namespace std;

int main() {
	int score = 10;
	int* score_ptr = &score;

	cout << *score_ptr << endl;

	*score_ptr = 20;
	cout << *score_ptr << endl;
	cout << score << endl;
}

 

동작 원리 (메모리 = 스택메모리)

1. int score = 10; int* score_ptr = &score;

2. cout << *score_ptr << endl; => *score_ptr의 메모리값이 가리키는 주소값의 메모리 값인 10 출력

3. *score_ptr = 20; => *score_ptr의 메모리 값이 가리키는 주소값의 메모리 값에 20을 저장

4. cout << *score_ptr << endl; => *score_ptr의 메모리 값이 가리키는 주소값의 메모리 20 출력

5. cout << score << endl; => score의 메모리 값은 20이기에 20 출력

 

예제2)

(*아래 예제의 주소값은 일반변수는 4바이트가 아닌 8바이트씩, 포인터변수는 4바이트씩 증가하는 것으로 이해바랍니다.)

#include <iostream>
using namespace std;

int main() {
	double high_temp = 100.7;
	double low_temp = 37.4;
	double* temp_ptr = &high_temp;

	cout << *temp_ptr << endl;
	temp_ptr = &low_temp;
	cout << *temp_ptr << endl;
}

 

1. double high_temp = 100.7; double low_temp = 37.4; double* temp_ptr = &high_temp;

 

2. cout << *temp_ptr << endl; => *temp_ptr의 메모리값이 가리키는 주소값의 메모리 값은 100.7 출력

 

3. temp_ptr = &low_temp; => temp_ptr의 메모리 값에 low_temp의 주소값을 저장

 

4. cout << *temp_ptr << endl; => *temp_ptr의 메모리 값이 가리키는 주소값의 메모리 값인 37.4 출력

 

 

 

 

1 - 5) 동적메모리 할당

- 런타임에 힙 메모리를 할당

 - 프로그램의 실행 도중 얼마나 많은 메모리가 필요한지 미리 알 수 없는 경우에 사용한다.

ex) 사용자 입력에 따라 크기가 바뀌는 경우, 파일을 선택하여 내용을 읽어오는 경우 등

 - 큰 데이터를 저장해야 할 경우(stack은 크기가 작음, 몊 MB 정도.)

 - 객체의 생애주기(언제 메모리가 할당되고 해제되어야 할지)를 직접 제어해야 할 경우

 

- 힙 메모리는 스택과는 달리 스스로 해제되지 않는다.

- 사용이 끝나고 해제하지 않으면 메모리 누수가 발생한다.

 

- new 연산자 사용

 - new의 역할(C의 maloc) Heap 메모리 공간에 int 하나를 감을 수 있는 메모리 주소를 찾고 주소값을 반

 

#include <iostream>
using namespace std;

int main() {
	int* int_ptr = nullptr;
	int_ptr = new int;	// allocate integer in heap
	cout << int_ptr << endl;	// 주소값
	cout << *int_ptr << endl;	// garbage value
	*int_ptr = 100;
	cout << *int_ptr << endl;	// 100
}

 

1. int* int_ptr = nullptr; => int* int_ptr 초기화

 

2. int_ptr = new int; => int* int_ptr의 스택 메모리 값에 new 하여 할당된 주소값을 저장

 

3. cout << int_ptr << endl; => int_ptr의 변수 안에 메모리 값인 0x2000 출력

 

4. cout << *int_ptr << endl; => *int_ptr은 역참조이다. 그래서 스택메모리 값을 참조하는 힙메모리 값인 garbage value 출력

 

5. *int_ptr = 100; => int_ptr의 역참조인 힙메모리 값에 100을 대입

 

6. cout << *int_ptr << endl; => 그리고 다시 int_ptr의 역참조 해서 힙메모리 값인 100을 출력한다.

 

(*스택 메모리와 힙 메모리를 이해하기 쉽도록 구분하였지만, 원래는 한 열에 연결되어 있다.)

 

동적 메모리의 해제

- delete 연산자 사용

#include <iostream>
using namespace std;

int main() {
	int* int_ptr = nullptr;
	int_ptr = new int;
	cout << int_ptr << endl;
	cout << *int_ptr << endl;
	*int_ptr = 100;
	cout << *int_ptr << endl;

	delete int_ptr;
	int_ptr = nullptr;
}

 

 - 해제하게 되면 아래와 같이 힙 메모리 값이 해제되어 메모리 누수를 방지한다.

 - int_ptr = nullptr;을 하지 않으면 스택 메모리에는 기존 힙메모리의 주소값을 반환하고 다른 주소값이 남아있게 된다.

 

동적 할당을 이용한 배열

- new[], delete[] 연산자 사용

#include <iostream>
using namespace std;

int main() {
	int* array_ptr = nullptr;
	int size = 0;

	cout << "size of array?";
	cin >> size;

	array_ptr = new int[size];

	array_ptr[0] = 10;
	array_ptr[1] = 20;
	array_ptr[2] = 30;

	delete[] array_ptr;
}

 

 

 

 

 

 

1 - 6) 포인터와 배열

- 배열의 이름은 배열의 첫 요소의 주소를 가리킨다.

- 포인터 변수의 값은 주소값이다.

- 포인터 변수와 배열이 같은 주소를 가리킨다면, 포인터 변수와 배열은 거의 동일하게 사용 가능하다.

 

#include <iostream>
using namespace std;

int main() {
	int scores[] = { 100, 95, 90 };
	cout << scores << endl;
	cout << *scores << endl;
	cout << scores[0] << endl;

	int* score_ptr = scores;
	cout << score_ptr << endl;
	cout << *score_ptr << endl;
	cout << score_ptr[0] << endl;
}

 

 

- 차이점으로는, 배열은 주소값을 정의 이후 변경이 불가하다. sizeof() 반환값이 다르다.

#include <iostream>
using namespace std;

int main() {
	int scores[] = { 100, 95, 90 };
	cout << sizeof(scores) << endl;

	int* score_ptr = scores;
	cout << sizeof(score_ptr) << endl;
}

 

- cout << sizeof(scores) << endl; => int scores[] 배열의 전체 크기

- cout << sizeof(score_ptr) << endl; => 포인터가 가리키는 요소의 크기

 

- 배열과 포인터의 사용법의 차이

#include <iostream>
using namespace std;

int main() {
	int num = 10;

	int scores[] = { 100, 95, 90 };
	cout << sizeof(scores) << endl;
	scores = &num;	// Error:식이 수정할 수 있는 lvalue 값이여야 한다.

	int* scores_ptr = scores;
	cout << sizeof(scores_ptr) << endl;
	scores_ptr = &num;	// Ok
}

 

 

 

 

1 - 7) 포인터와 const

1. const의 포인터(pointers to const)

 - 데이터가 const / 포인터는 다른 데이터를 가리킬 수 있다.

 - 상수를 가리키는 포인터는 상수 변수의 주소를 가리키는 (non-const)포인터다.

 

#include <iostream>
using namespace std;

int main() {
	int highScore = 100;
	int lowScore = 60;

	const int* scorePtr = &highScore;

	//*scorePtr = 80;	// ERROR!
	scorePtr = &lowScore;	// OK

	cout << scorePtr << endl;
}

 

다음과 같은 error가 발생한다.

 

 

2. const인 포인터(const pointers)

 - 포인터가 const / 데이터는 변할 수 있다.

#include <iostream>
using namespace std;

int main() {
	int highScore = 100;
	int lowScore = 60;

	int* const scorePtr = &highScore;

	*scorePtr = 80;	// OK
	//scorePtr = &lowScore;	// Error

	cout << *scorePtr << endl;
	cout << scorePtr << endl;
	cout << &lowScore << endl;
	cout << &highScore << endl;
}

 

 

 

3. const의 const인 포인터(const pointers to const)

 - 둘 다 const인 경우

#include <iostream>
using namespace std;

int main() {
	int highScore = 100;
	int lowScore = 60;

	const int* const scorePtr = &highScore;

	//*scorePtr = 80;	// ERROR
	//scorePtr = &lowScore;	// ERROR
}

 

 

1 - 8) 함수에 포인터 전달하기

- 포인터를 함수의 인자로 전달

 - pass-by-address / 변수의 주소를 전달한다.

 

#include <iostream>
using namespace std;

void DoubleData(int* intPtr) {
	*intPtr *= 2;
}

int main() {
	int value = 10;
	cout << value << endl;
	DoubleData(&value);
	cout << value << endl;
}

 

DoubleData() 호출 시점의 메모리 상태

 

DoubleData() 호출 종료 시점의 메모리 상태

 - 주소를 가지고 있으니 호출 Stack 밖의 값도 바꿀 수 있다.

 

- 포인터의 반환

 - 인자로 전달된 데이터(포인터)를 반환 -> OK

int* LargerInt(int* intPtr1, int* intPtr2) {
	if (*intPtr1 > *intPtr2) {
		return intPtr1;
	}
	else {
		return intPtr2;
	}
}

 

 - 함수 내부에서 동적으로 할당된 메모리의 주소를 반환-> OK

#include <iostream>
using namespace std;

int* CreateArray(int size, int initValue = 0) {
	int* newStorage = nullptr;
	newStorage = new int[size];
	for (int i = 0; i < size; ++i) {
		*(newStorage + i) = initValue;
	}
	return newStorage;
}

int main() {
	int* myArray = nullptr;

	myArray = CreateArray(100, 10);

	delete[] myArray;
	return 0;
}

 

- 지역 변수에 대한 포인터 반환-> 안된다!

#include <iostream>
using namespace std;

int* DontDoThis() {
	int num = 10;
	int* numPtr = &num;

	return numPtr;
}

void main() {
	int* a = nullptr;
	a = DontDoThis();
	cout << *a << endl;
}

 

1. DontDoThis()함수가 호출되었을 때 메모리는 아래와 같다.

 

 

2. 그러나 DontDoThis() 함수가 종료되면 num 변수는 스택에서 해제되고, 해당 주소는 더 이상 유효하지 않다.

 

 

3. 따라서 DontDoThis()가 종료되고 포인터 numPtr이 반환될 때 해당 포인터가 가리키는 메모리 주소는 더 이상 유효하지 않습니다. 그래서 더 이상 유효하지 않은 메모리 주소를 가리키는 포인터를 사용하면 예기치 않은 동작이 발생할 수 있다.

 

4. 그래서 해결방법은 다양하겠지만, 동적으로 메모리에 할당하여 해당 메모리의 주소를 반환하는 방법이 있다. ->해결O

#include <iostream>
using namespace std;

int* DontDoThis() {
    int* numPtr = new int(10); // 동적으로 메모리 할당

    return numPtr;
}

int main() {
    int* a = nullptr;
    a = DontDoThis();
    cout << *a << endl;

    delete a; // 동적으로 할당된 메모리 해제

    return 0;
}

 

 

1 - 9) 주의사항

- 초기화의 필요성

int* intPtr;	// anywhere
...
*intPtr = 100;	// in VS, compiler protects

 

- 허상 포인터(dangling pointer)

 - 두 포인터가 동일 데이터를 가리키다가 하나의 포인터가 메모리를 해제할 경우

 - 지역 변수를 참조하고, 호출 스택이 끝나는 경우

 

#include <iostream>
using namespace std;

int main() {
	int* val = new int;
	*val = 10;

	int* val2 = val;

	delete val2;
	val2 = nullptr;

	cout << *val << endl;
}

 

 - int* val은 new int의 주소값을 가리키고 있고, *val = 10; 이기에 10의 값이 메모리에 할당된다. 그리고 int* val2이 val의 주소값을 가리키고 delete를 통해 val2를 해제한다. 후에 초기화를 시켜주어 아래와 같이 val2는 초기화가 잘 되었다. 그러나 *val은 이미 해제된 val2의 메모리를 참조하기에 쓰레기값이 출력된다.

 

 

- new의 실패

 - 가끔 발생할 수 있음, 이런 경우 예외 처리 필요

 

- 메모리 누수(Memory leak)

 - 동적 할당으로 사용한 힙 메모리는 반드시 해제해야 한다.

 

 

 

※본 블로그는 학습을 하며 제가 이해한 내용을 바탕으로 작성되어 실제 정의와 다를 수 있습니다.

 

참고문헌

GitHub - diskhkme/cpp_lecture_material: C++ 프로그래밍 강의 자료

https://www.aladin.co.kr/shop/wproduct.aspx?ISBN=K842939734&start=pnaver_02

'ZAION > C++' 카테고리의 다른 글

[C++]Lec04-02. Pointer & Reference  (2) 2024.04.30
[C++]Lec03. Function  (0) 2024.04.25
[C++]Lec02. Debugging  (1) 2024.04.24
[C++]Lec02. Basic Syntax  (0) 2024.04.23
[C++]Lec01. Variable and Constant  (2) 2024.04.23