프로그래머/CPP_메모

c++ 포인터 정리

미역국마싯 2022. 3. 20. 11:12

목록

1. 포인터

2. 선언과 초기화

  1. 포인터 선언
  2. 포인터 초기화
  3. 포인터는 고정 크기를 가지는데 왜 타입을 명시할까?
  4. 포인터 사용 예시 ( 1 )
  5. 포인터 사용 예시 ( 2 )

3. 메모리 크기 불일치

4. 포인터 연산자

  1. 주소 연산자
  2. 간접 연산자
  3. 산술 연산자
  4. 간접 멤버 연산자

5. 포인터를 매개 변수로 받는 함수의 보험

  1. const가 *앞에 위치
  2. const가 *뒤에 위치

6. 포인터가 가리키는 주소가 존재하지 않는다?

  1. nullptr을 넘겨주는 포인터

7. 다중 포인터


 

포인터

int number = 1;

해당 코드의 의미를 알아보자.

  1. number라는 이름의 4 byte 정수 타입의 바구니를 만든다.
  2. number라는 변수는 스택 메모리에 할당한다.
  3. number = 1은 number 바구니에 정수 1을 넣는다.

즉, 스택 메모리에 있는 특정 주소(number 바구니)에 우리가 원하는 이름을 지정하고 값을 넣는 방법이다.

장점은 편리하지만 단점은 원본을 수정할 수 없다는 것이다.

이러한 단점을 보완하기 위해서 포인터를 사용한다.


 

선언과 초기화

1. 포인터 선언

type* 변수이름;
int* ptr;

그 전까지 데이터를 저장하는 것과 달리, 포인터는 주소를 저장하는 바구니이다.

참고로 포인터의 크기는 32bit 환경에서는 4 byte, 64bit 환경에서는 8 byte로 고정된 크기를 가진다.

 

2. 포인터 초기화

int* ptr = &number;		// &을 통해 주소를 넘겨준다

&을 이용해서 number의 주소를 ptr에 저장한다.

 

3. 포인터는 고정 크기를 가지는데 왜 타입을 명시할까?

간단히 말하면 포인터가 가리키는 주소에 저장되어있는 데이터의 해석방법을 나타낸다.

즉, ptr은 number의 데이터를 4byte 정수처럼 해석한다.

 

4. 포인터 사용 예시 ( 1 )

// 1
int getValue = *ptr;

// 2
*ptr = 2;

1) [주소를 저장하는 바구니(포인터)]가 가리키는 주소(&number)로 가서 해당 값을 value에 저장한다.

즉, 포인터가 가리키는 주소로 가서 무엇인가를 하고 싶을 때 사용한다.

 

2) 포인터가 가리키는 주소에 2를 저장해라.

즉, number에는 1이 아니라 2가 저장된다.

 

!!!! 헷갈리면 이렇게 생각해보자 !!!!

사용시점에 따라 구분해서 기억한다.

1) 변수를 선언할 때, [ 주소를 저장하는 바구니 ]다.

int* ptr = &number;

2) 변수이름 앞에 *이 붙으면, [ 포탈을 타고 순간이동 ] 하는 것이다.

int getValue = *ptr;	# 1)
*ptr = 2;		# 2)

1) ptr이 가리키는 주소로 포탈을 타고 이동한다. 포탈을 타고 도착한 장소에 있는 value를 꺼내서 getValue에 저장한다.

2) ptr이 가리키는 주소로 포탈을 타고 이동한 후 2를 저장한다.

 

5. 포인터 사용 예시 (2)

매개 변수로써 포인터는 어떻게 사용할까?

void Test(int* ptr)
{
	*ptr = 100;
}

int main()
{
	int value = 1;
	Test(&value);
}

함수의 매개 변수로 값을 받을 때는 주소값을 받아야 하기 때문에 &를 넣어주면 된다.

함수를 정의할 때는 선언문을 작성한다고 생각하면 된다.

해당 예시에서는 선언한 포인터 변수에 value의 주소가 들어왔고, " *ptr "을 통해 ptr이 가지고 있는 주소 포탈을 타고 들어가 value에 접근한 뒤 2를 100으로 변경했다.

포인터는 목적은 원본 데이터를 건드리는 것임을 다시 한 번 상기시켰다.


 

메모리 크기의 불일치

만약 포인터의 데이터 타입과 포인터가 가리키는 주소 내 메모리 크기가 다르다면 어떤 일이 발생할까?

int number = 1;

// number는 4byte 정수를 저장하고 있지만
// 타입 불일치를 실험하기 위해 8byte로 캐스팅했다.
__int64* ptr2 = (__int64*)&number;

// 8byte 데이터 저장
*ptr2 = 0xAABBCCDDEEFF; // AABB(2byte)CCDDEEFF(4byte)

위 예시에서는 ptr2를 이용해서 number 주소 내의 데이터를 8byte(__int64) 크기를 가진 데이터로 저장했다.

number는 4byte 정수를 저장하는데 포인터가 넣은 값은 8byte이므로 number 주소에는 CCDDEEFF만 저장되고, 그 다음 주소에 0000AABB가 저장된다. 즉, 저장된 데이터가 기대값과 달라지는 경우가 발생한다.

이를 순서대로 정리하면 다음과 같다.

1. number[00000001]		// 4byte 정수 1이 저장
2. ptr2가 number의 메모리 주소를 가리킨다
3. ptr2에 0xAABBCCDDEEFF를 저장	// 8byte 정수를 저장
4. number  [CCDDEEFF]	
   number+1[0000AABB]	// 초과된 데이터는 밀려서 다음 주소에 저장

이런식으로 데이터가 밀리면 잘못된 값이 특정 주소에 덮어쓰여지는 현상이 발생할 수 있다.


 

포인터 연산자

1. 주소 연산자( & )

int number = 1;

int* pointer = &number;		// return int* address;

 

& 연산자는 해당 변수(number)의 주소를 알려준다고 생각하면 된다. 정확하게 따지면, 해당 변수(number)의 type에 따라서 TYPE* 를 반환한다.

 

2. 간접 연산자( * )

// 서로 같은 의미
number = 3;
*pointer = 3;

pointer가 가리키는 포탈을 타고 이동해서 3을 저장한다. 이때 이동한 곳은 number가 가진 값이 존재한다.

 

3. 산술 연산자 ( + , - )

pointer에 1을 더하면 어떻게 될까?

// 모두 사용 가능
pointer = pointer + 1;
pointer++;
++pointer;
pointer += 1;

pointer를 1 증가시키고 메모리를 들여다보면 pointer에 저장된 주소가 4만큼( 64bit면 8 ) 증가된 것을 확인할 수 있다.

포인터에서 산술 연산으로 1을 더하거나 빼면 그 숫자를 더하고 빼라는 것이 아니다.

[ 바구니 단위 ] 이동이며, 다음/이전 바구니( 주소 )로 이동한다고 생각하면 편하다.

 

여기서 pointer가 가지고 있는 주소를 4번 증가시켰다. 그러면 number의 주소는 변할까?

number의 주소는 변하지 않는다. 단지 pointer는 number 주소의 다다다다음 주소를 가리킨다.

따라서 지금 pointer는 엉뚱한 메모리에 접근하고 있다.

 

4. 간접 멤버 연산자( -> )

struct Player
{
	int hp;		// 메모리 주소: 0
	int damage;	// 메모리 주소: 4
};

// 일반적인 구조체 사용법
Player player;
player.hp = 100;
player.damage = 10;

// 자체적으로 정의한 타입에도 포인터를 사용할 수 있다.
Player* playerPtr = &player;
(*playerPtr).hp = 200;		// 메모리 주소: 0을 조작
(*playerPtr).damage = 200;	// 메모리 주소: 0+4를 조작

구조체와 클래스에서 사용할 수 있는 연산자다.

구조체로 자체적으로 정의한 타입은 첫 번째 멤버를 기준으로 메모리 주소를 구성하고 있다.

hp와 damage는 int( 4byte )이므로 0번 째 메모리를 차지하고, damage는 그 다음 주소( +4 )의 메모리를 차지하고 있다.

 

마찬가지로 ( *playerPtr ).hp는 특정 메모리에 저장되어 있다. 이때 ( *playerPtr ).damage 주소를 알려면, (*playerPtr).hp 주소에 +4를 하면 된다. Player의 정보가 저장된 메모리는 player를 만들 때마다 변하기 때문에 정확한 주소값은 알 수 없다.

 

playerPtr에 접근하는 방법은 매우 귀찮다. 이를 보완하기 위한 방법이 간접 멤버 연산자이다.

playerPtr->hp = 200;		// == (*playerPtr).hp
playerPtr->damage = 200;	// == (*playerPtr).damage

간접 멤버 연산자는 포인터의 *과 구조체의 . 을 합친 거라고 보면 된다.

즉, playerPtr이 가리키는 주소( player )로 간 다음에 해당 주소의 특정 멤버( hp 또는 damage )를 건드린다.

 

추가) 구조체끼리 복사할 때 무슨 일이 벌어질까?

struct Stat
{
    int hp;
    int damage;
};

Stat player;
Stat monster;

// 1
player = monster;

// 2
player.hp = monster.hp;
player.damage = monster.damage;

// 1과 2는 같은 결과가 나온다.

 

포인터를 매개 변수로 받는 함수의 보험

포인터를 매개 변수로 받는 이유는 원본 데이터를 수정하는 것도 있지만, 성능적으로 뛰어나기 때문인 경우도 있다.

앞서 말했듯이 포인터는 고정 크기를 가진다. 따라서 값 전달 방식은 값의 크기가 크면 비효율적으로 동작하지만, 주소 전달 방식인 포인터는 4 또는 8 byte만 사용하기 때문에 효율적이다.

void Test(Stat* stat)
{
	std::cout << stat->hp << '\n';
}

하지만 다른 팀원은 해당 함수가 원본 데이터를 건드리지 않는다는 사실을 모를 수 있기 때문에 문제가 발생할 수 있다.

따라서 원본 데이터를 건드리지 않는다는 표시를 하기 위해 const를 붙여서 상수화 한다.

void Test(const Stat* stat)
{
	std::cout << stat->hp << '\n';
	stat->hp = 100;		// error!
}

const의 위치에 따라 의미가 달라지는 경우도 있다.

 

1. const가 *앞에 위치

Stat testStat;
void Test(const Stat* stat)
{
	stat = &testStat;
	stat->hp = 100;		// error!
}

void Test(Stat const* stat)
{
	stat = &testStat;
	stat->hp = 100;		// error!
}

2가지 방법이 있는데 일반적으로 첫 번째 방식을 사용한다. const가 *앞에 위치하면 stat이 가진 주소에 있는 값을 변경할 수 없다. 즉, stat이 가진 주소에 있는 값을 고정할 때 사용한다.

 

  • 포인터의 주소값을 변경할 수 있다.
  • 포인터가 가진 주소에 있는 값을 변경할 수 없다.

 

2. const가 *뒤에 위치

Stat testStat;
void Test(Stat* const stat)
{
	stat->hp = 100;
	stat = &testStat;	// error !
}

const가 *뒤에 위치하면 stat의 주소를 바꿀 수 없다. 즉, stat이 가진 주소값을 고정할 때 사용한다.

  • 포인터가 가진 주소에 있는 값을 변경할 수 있다.
  • 포인터의 주소값을 변경할 수 없다.

 

참고로 const를 * 앞/뒤로 위치시킬 수 있다.


 

포인터가 가리키는 주소가 존재하지 않는다?

포인터는 선언과 동시에 초기화를 해주지 않아도 된다. 따라서 해당 포인터에 주소가 저장되지 않을 수 있다. 이를 어떻게 표현할까?

Stat* ptr;

ptr = 0;
ptr = NULL;
ptr = nullptr;	// 해당 방식을 사용하자

3가지 방법이 있다. 0을 넣는 것, NULL을 넣는것, nullptr을 넣는것이다. 여기서 nullptr을 사용한다.

 

nullptr을 넘겨주는 포인터

포인터는 nullptr을 넘겨줄 수 있기 때문에 함수에서 사용할 때는 if문을 통해 확인해주는 것이 좋다.

nullptr을 넘겨줬을 때, crash가 발생할 수 있기 때문이다.

void Test(const Stat* const stat)
{
	if (stat == nullptr)
		return;
        
	std::cout << stat->hp << '\n';
}

 

다중 포인터

앞에서 설명했을 때는 const char* 는 내용물을 바꾸지 못하게 한다는 것을 알 수 있다. 하지만 내용물을 바꾸는 방법이 있다. 포인터가 가리키는 주소를 바꾸면 된다.

void TestOne(const char* str)
{
	str = "change";
}

// 일반 포인터
const char* msg = "Hello";	// msg -> Hello의 첫 주소를 가리킨다.
TestOne(msg);

 

여기서는 "change"가 저장되어 있는 메모리 주소를 포인터가 가리키도록 하면 된다.

msg [ "Hello" ]
TestOne()
msg [ "change" ]

이런 식으로 하면 앞에서 말했듯이 msg의 주소를 변경할 수 있다. 하지만 TestOne이 끝나고 msg를 살펴보면 주소가 변하지 않음을 알 수 있을 것이다.

왜 그런지 알기 위해서 차근차근 살펴보자.

// 1
TestOne [매개변수(str<"Hello"주소>][RETURN][지역변수]
main [매개변수][RETURN][지역변수(msg<"Hello"주소>)]

// 2
TestOne [매개변수(str<"change"주소>][RETURN][지역변수]
main [매개변수][RETURN][지역변수(msg<"Hello"주소>)]

// 3
main [매개변수][RETURN][지역변수(msg<"Hello"주소>)]

1. main에서 TestOne에 msg가 가지고 있는 "Hello" 주소를 보낸다. 그러면 TestOne의 매개 변수 str에 "Hello" 주소가 오게 된다. 

2. TestOne에서 str에 "change" 주소를 넣기 때문에 str가 가진 주소가 바뀐다.

3. TestOne이 끝나면 메모리에서 삭제된다. 그러면 msg의 주소는 변하지 않음을 알 수 있다.

 

따라서 주소를 바꾸고 싶었는데 바꾸지 못한 상태가 된다. 이럴 때 다중 포인터를 이용한다.

void TestTwo(const char** str)
{
	*str = "change";
}

// 다중 포인터
const char** pp = &msg;
TestTwo(pp);
TestTwo(&msg);

차근차근 살펴보면

1. * pp는 일반적인 포인터다. 따라서 pp[ 주소1 ]처럼 주소를 가지고 있다.

2. ** pp를 보면 pp가 가지고 있는 주소1의 주소를 가지고 있다. 즉, 주소1[ 주소2 ]

3. const char로 인해 주소2에는 const char형 데이터가 저장되어 있다.

1. *pp == pp[ 주소1 ] 8 byte
2. (const char*)* pp == 주소1[ 주소2 ] 8 byte
3. 주소2[ "data" ] 

이를 위 코드에 대입해보면

const char* msg = "Hello";
1. msg는 "Hello"의 주소를 가지고 있다.

const char** pp는 "Hello"의 주소를 가진 msg의 주소를 가질 수 있다.
pp [ &msg ]
2. (const char*)* pp = &msg;

즉, 포인터가 가진 주소를 바꾸고 싶으면 다중 포인터인 pp를 이용해야 한다.

다중 포인터는 포탈( ** )이 2개다!

pp [ msg [ "Hello" ] ]
TestTwo()
pp [ msg [ "change" ] ]

'프로그래머 > CPP_메모' 카테고리의 다른 글

c++ 연산자 오버로딩 정리  (0) 2022.04.03
c++ 배열 정리  (0) 2022.03.21
c++ 참조 정리  (0) 2022.03.20
c++의 다양한 input 방법과 속도 비교  (0) 2022.03.17
header  (0) 2022.03.16