🧠프로그래밍/C++

포인터 이해하기

악마반장 2022. 10. 27. 17:30

우리는 변수를 선언하면 공간을 할당하고 그 메모리에는 주소가 부여되어(16진수로 표현) 있다. 즉, 내부적으로 해당 공간의 주소를 알고 있는 것이 되는 것.


C++ 뿐만 아니라, 타 언어 또한 모두 주소의 원리로 이루어지고 있다.

발 그림 죄송합니다..

 

 

포인터

포인터 타입의 변수는 변수의 시작 메모리 주소를 저장하는 역할을 한다.

기능은 단순히 메모리 주소 저장 역할만 한다고 보면 되고, 변수 그 자체라고 생각하면 안된다.

또한, 모든 변수 타입(사용자 정의 변수 타입 포함)들은 포인터 타입을 가질 수 있다.

int의 포인터 변수이면 int 타입 변수의 메모리 주소를 저장하고 float의 포인터 변수이면 float 타입 변수의 메모리 주소를 저장하는 방식이다. 하지만 반. 드. 시 타입을 맞춰서 사용해야 한다.

 

 

 

포인터의 크기

주소에 대한 이야기를 잠깐 해보자.

메모리 주소는 개발환경에 따라 크기가 바뀐다.

unsigned int로 표현할 수 있는 최대 값은 '4,294,967,295'이다. 여기서 1024로 나누어 주면 4,194,303 kbyte가 나오고 또 여기서 1024로 나누어 주면 4,095 mbyte가 되고, 여기서 또 1024로 나누면 결과는 3.5 gbyte 정도이다. 그래서 32비트 운영체제는 실제로 메모리 주소를 부여할 수 있는 것이 3.5gb까지만 가능하여 8GB 메모리를 장착하더라도 32bit 컴퓨터는 최대 4GB까지(실제 3.5gb)만 접근 가능하다.

 

 

소프트웨어들이 점점 메모리 공간을 많이 차지하게 되면서 나온 것이 바로 64bit 운영체제이다.

 

값이 굉장히 크다..

그래서 x86(32bit)로 개발할 때는 메모리 주소는 16진수로 8자리가 되고 x64(64bit)로 개발할 때는 메모리 주소는 16진수 16자리가 된다. 16진수 1자리는 2진수 4자리이므로 4bit를 차지하고 8자리라면 32bit를 차지하게 된다. 즉, x86에서 메모리 주소를 저장하려면 4byte이 공간이 필요하고, x64에서 메모리 주소를 저장하려면 16자리이므로 64bit가 필요하고 8byte가 필요하다는 것이다.

 

32bit 환경 8자리수
64bit 16자리

32비트 운영체제 : 메모리 주소를 32비트 크기만큼만 사용할 수 있다.
64비트 운영체제 : 메모리 주소를 64비트 크기만큼만 사용할 수 있다.

 

또한, 포인터는 근본적으로 메모리를 저장하는 공간이기에 byte가 변수 타입에 상관없이 모두 동일하다.

 

 

 

포인터의 선언 방법

변수 타입* 포인터명;으로 선언

#include <iostream>

int main()
{
	int Number = 100;
	int* pNumber = &Number;
    
    return 0;
}

일반 int 타입 변수인 Number를 선언하고 포인터 타입의 변수 pNumber를 선언 후, Number의 주소를 넣어 주었다.

 

그래서 결과를 보면, pNumber의 값과 Number의 주소 값이 동일한 것을 확인할 수 있다.

 

 

그래서 쉽게 그림으로 그려본다면, pNumber의 변수가 Number를 참조하고 있게 되는 것이다.

int* pNumber3 = nullptr;

또한, 포인터를 선언하면 반드시 초기화를 진행하고 사용하자.

 

 

 

역참조

포인터 변수가 가지고 있는 주소 값에 접근하는 것을 '역참조'라고 한다.

#include <iostream>
int main()
{
	int Number = 100;
	int Number1 = 200;
	int* pNumber = &Number;

	std::cout << "Pointer Size : " << sizeof(int*) << std::endl;
	std::cout << "Pointer Size : " << sizeof(char*) << std::endl;
	
	std::cout << "Number Value : " << Number << std::endl;
	std::cout << "Number1 Value : " << Number1 << std::endl;
	std::cout << "Number Address : " << &Number << std::endl;
	std::cout << "Number1 Address : " << &Number1 << std::endl;
	std::cout << "pNumber Value : " << pNumber << std::endl;
	std::cout << "pNumber Address : " << &pNumber << std::endl;
	std::cout << "pNumber Ref Value : " << *pNumber << std::endl;
    
    return 0;
 }

그래서 위와 같이 역참조를 하게 되면

 

Number가 가지고 있는 100을 pNumber에서 동일하게 접근이 가능하고 지금은 단순히 값을 가져와서 출력을 하였지만, 값을 변경할 수 도 있다. '*pNumber = 1234'라고 해줄 경우 Number의 값이 바뀐다.

 

 

 

상수 포인터

'상수'는 변하지 않는 값을 뜻한다. 따라서 변수 타입 앞에 const를 붙이면 상수가 된다.

포인터 타입에 'const'를 이용하여 상수 포인터 타입을 만들어낼 수 있으며, 상수 포인터 타입은 3가지의 형태로 나뉠 수 있다.

const int* : 참조하는 대상은 얼마든지 변경이 가능하지만 참조하는 대상의 메모리의 접근하여 값을 바꾸는 것은 안되며, 값을 얻어다 사용하는 것은 가능하다.

int* const : 참조하는 대상을 변경할 수 없다. 처음 초기화할 때 한번 참조가 정해지면 그 대상만을 참조하게 된다.

const int* const : 참조하는 대상도 변경할 수 없고 참고하는 대상의 값도 변경할 수 없다.

 

세 가지 타입의 차이를 에러 부분을 통해 명확하게 파악할 수 있다.

 

그런데, 굳이 const 키워드를 사용해서 포인터를 사용해야 할까?? 이런 의문을 가질 수 있다.

이 const 키워드를 붙여서 정말 대단한(?) 포인터의 기능을 만드는 건 아니지만, 만약에 협업을 통한 프로젝트를 한다면 다른 사람이 값을 바꾸거나 참조하는 대상을 바꿔 버릴 수도 있다. 

 

이전 포스팅 '포인터와 컴퓨터 구조의 관계'에서 '포인터는 어떤 주소든 가리킬 수 있는 변수이니 주소를 잘못 넣었을 때 프로그래머가 소유하지 않은 메모리에 쓸 수 있기 때문에 예상하지 못한 버그가 발생할 수 있다'라고 언급한 부분이 있다.

그래서 내가 참조하는 주소나 값이 변경되는 것을 원치 않는다면 반드시 'const'를 붙여줄 필요가 있다.

 

 

 

댕글링 포인터

{
	//이 변수는 해당 코드블럭 안에서만 존재할 수 있다.
	int Number4 = 1010;
	pNumber3 = &Number4;
}

코드 블록 안에서 pNumber3에  Number4의 메모리 주소를 넣어주었는데 여기까지 코드가 진행되면 Number4 변수는 더 이상 사용할 수 없는 변수가 된다. 그래서 pNumber3는 더 이상 사용할 수 없는 즉, 해제된 메모리 주소를 가지고 있게 되므로 이를 역참조 할 때 문제가 발생하게 된다. 이렇게 해제된 메모리 주소를 가지고 있는 포인터를 댕글링 포인터라고 한다.

 

 

 

배열명은 포인터?

배열명을 단순히 포인터라고 보기보다 좀 더 자세하게 말하면 특정 자료형의 배열형은 특정 자료형의 포인터로 변환이 가능하다. 결론은 배열명은 배열명이고 포인터는 포인터이다.

#include <iostream>
int main()
{
	int Array[100] = {};
	std::cout << Array << std::endl;
    
    return 0;
}

위와 같이 코드를 입력하고 실행시켜 보면

주소 값이 나오는 것을 확인할 수 있다.

그래서 배열의 인덱스 접근은 내부적으로 포인터 연산을 통하여 처리하고 있다는 것을 알 수 있다.

#include <iostream>

int main()
{

	int Array[100] = {};
	std::cout << Array << std::endl;
	std::cout << &Array[0] << std::endl;
    
	return 0;
}

배열의 이름과 배열의 첫 번째 원소의 주소값이 같다라는 것을 알 수 있다.

그렇다면 배열명이 곧 포인터 아니야? 배열명이 곧 첫 번째 원소의 주소값을 가리키는 것이 아닐까? 아님

배열의 이름을 사용 시 암묵적으로 첫 번째 원소를 가리키는 포인터로 타입 변환되기 때문이다.

https://modoocode.com/25 해당 사이트에 잘 설명이 되어있으니 참고하자. 절대 '같다'라고 오해하면 안 된다. 

 

#include <iostream>
int main()
{
	int Array[100] = {};
	std::cout << "Array Address : " << Array << std::endl;

	std::cout << "Array [3] Value : " << Array[3] << std::endl; // 주소의 3번

	int* pArray = Array; // 메모리 주소기 때문에 가능

	pArray[3] = 400;
	std::cout << "Array [3] Value : " << Array[3] << std::endl;

	return 0;
}

그래서 위 코드를 입력하고 실행시켜 보면, 

 

위와 같은 결과가 나온다. 배열명이 첫 번째 원소의 주소값으로 변환되었기 때문에 'int* pArray = Array' 포인터 변수에 넣어줄 수 있다. 그래서 인덱스 3번에 접근해서 'pArray [3] = 400'으로 값을 바꾸어 줄 수 있음.

 

배열과 관련된 또 다른 특징은 포인터도 변수이기 때문에 배열 선언이 가능하다.

	int* pNumberArray[2] = {};

	pNumberArray[0] = &Number;
	pNumberArray[1] = &Number1;

원하는 주소를 담아놓는 배열을 만들 수 있다.

 

 

 

이중 포인터

일반 변수의 주소를 가질 수는 없으며, 포인터 변수의 메모리 주소를 저장하는 변수이다.

이중포인터^^

int main()
{
	int Number = 100;
	int* pNumber = &Number;
	int** ppNumber = &pNumber;
}

위와 같은 식으로 관계를 만들어 줄 수 있다.

 

#include <iostream>

int main()
{
	/*
	이중포인터 : 일반 포인터 변수의 메모리 주소를 저장하는 변수이다.
	*/
	int Number = 100;
	int* pNumber = &Number;
	int** ppNumber = &pNumber;

	std::cout << "Number : " << Number << std::endl;
	std::cout << "Number Address : " << &Number << std::endl;
	std::cout << "pNumber : " << pNumber << std::endl;
	std::cout << "*pNumber : " << *pNumber << std::endl;
	std::cout << "pNumber Address : " << &pNumber << std::endl;
	std::cout << "ppNumber : " << ppNumber << std::endl;
	std::cout << "*ppNumber : " << *ppNumber << std::endl;
	std::cout << "**ppNumber : " << **ppNumber << std::endl;
	std::cout << "ppNumber Address : " << &ppNumber << std::endl;

	return 0;
}

해당 결과를 통해 서로의 관계를 더 잘 파악할 수 있다.

 

 

 

포인터의 연산

포인터 또한 연산이 존재하고, 포인터는 +,- 2가지 연산을 지원한다.

#include <iostream>
int main()
{
	int Array[100] = {};
	std::cout << "Array Address : " << Array << std::endl;

	std::cout << "Array [3] Value : " << Array[3] << std::endl; // 주소의 3번

	int* pArray = Array; // 메모리 주소기 때문에 가능

	pArray[3] = 400;
	std::cout << "Array [3] Value : " << Array[3] << std::endl;

	std::cout << "pArray Value : " << pArray << std::endl;
	std::cout << "pArray Value  +1 : " << pArray + 1 << std::endl;
	std::cout << "pArray Value  +2 :" << pArray + 2 << std::endl;
	return 0;
}

포인터 연산은 1을 더한다고 1이 늘어나는 것이 아니고, int 포인터의 경우 int의 크기가 4바이트이므로 주소에 1을 더해주게 된다면 4바이트만큼 증가된 주소를 의미하게 된다. 

 

즉, 포인터의 연산은 덧셈이나 뺄셈 모두 마찬가지로 1을 더하거나 빼게 되면 해당 포인터의 타입의 크기만큼 증가하거나 감소하게 된다.

 

그래서 맨 뒤에 주소를 보면 0 -> 4 -> 8씩 int의 크기인 4 바이트만 큼씩 증가하는 것을 볼 수 있다.

 

#include <iostream>
int main()
{
	int Array[100] = {};
	std::cout << "Array Address : " << Array << std::endl;

	std::cout << "Array [3] Value : " << Array[3] << std::endl; // 주소의 3번

	int* pArray = Array; // 메모리 주소기 때문에 가능

	pArray[3] = 400;
	std::cout << "Array [3] Value : " << Array[3] << std::endl;

	std::cout << "pArray Value : " << pArray << std::endl;
	std::cout << "pArray Value  +1 : " << pArray + 1 << std::endl;
	std::cout << "pArray Value  +2 :" << pArray + 2 << std::endl;

	bool bTest[32] = {};
	bool* pTest = bTest;

	std::cout << "pTest Value : " << pTest << std::endl;
	std::cout << "pTest Value + 1 : " << pTest + 1 << std::endl;
	std::cout << "pTest Value + 2 : " << pTest + 2 << std::endl;
	return 0;
}

int뿐만 아니라 bool변수를 출력해 보면

 

뒤에 주소 값이 1씩 증가하는 것을 확인할 수 있다.

 

Array [3]은 시작 주소에서 +3을 하게 되는 것으로 12바이트만큼 늘어난다. 그래서 결국 배열의 인덱스를 접근하는 방식이 포인터 연산인 것을 알 수 있다.

#include <iostream>
int main()
{
	int Array[100] = {};

	int* pArray = Array; // 메모리 주소기 때문에 가능

	pArray[3] = 400;
    
    std::cout << "Array [3] Value : " << Array[3] << std::endl;
    
    *(pArray + 3) = 999;
    
	std::cout << "Array [3] Value : " << Array[3] << std::endl;

	return 0;
}

그래서 위 코드를 실행시켜 보면

다시 999로 바뀐 것을 볼 수 있다.

pArray [3] = 400 이 연산이 내부적으로 *(pArray + 3) 이렇게 일어나고 있는 것이다.

 

 

 

구조체 포인터 선언

구조체의 경우에도 배열의 포인터 선언과 다르지 않다.

다만, 구조체의 멤버를 역참조 할 때, 아래와 같은 에러 메시지가 나타난다.

역참조 또한 연산자이기 때문에 연산자 우선순위에 따라. 이 * 보다 먼저 연산이 된다.

*pPlayer를 괄호로 먼저 묶어서 역참조를 하게 해 준다.

하지만 위처럼 선언하는 것보다는 화살표를 이용해서 참조하는 경우가 더 많다.

#include <iostream>
enum class JOB
{
	Knight = 1,
	Archer,
	Magicion
};
struct  Player
{
	char Name[32];
	JOB Job;
	int Attack;
	int Armor;
	int HP;
	int HPMax;
	int MP;
	int MPMax;
	int Critical;
	int Level;
	int Exp;
};
int main()
{
	Player tPlayer = {};
	Player* pPlayer = &tPlayer;
	(*pPlayer).Attack = 100;
	pPlayer->Attack = 400;

	std::cout << tPlayer.Attack << std::endl;
	// 400 출력

	return 0;
}

 

포인터에 관련된 기본 이론적인 부분을 정리해 보았고, 아직 좀 더 설명해야 하는 메모리 해제에서의 댕글링 포인터 문제와 스마트 포인터 등등.. 정리해야 할 부분이 있기 때문에 이 부분은 추가적으로 게시물로 작성할 예정.