[C++] 포인터(Pointer), 포인터 배열

2024. 3. 12. 23:11Programming Language/C++

아래 링크 클릭 시 해당 본문으로 이동

포인터

포인터 배열


포인터

주소를 가리키는(저장하는) 변수

포인터 변수라고도 한다.

포인터라는 개념은 C, C++에 있고 다른 언어에는 없다.

• 포인터가 가리키는 곳을 해당 포인터 변수의 자료형으로 해석한다.

  ex) int 포인터 변수가 가리키는 곳을 int로 해석한다.

포인터의 기능

• 주소를 가리킬 수 있고 주소를 받는 변수를 선언할 수 있다.

• 주소 변수를 이용하여 내가 알고 있는 주소로 접근하고 값을 직접 수정할 수 있다.

 주소 변수는 다른 것을 저장할 수 없고 주소만 저장할 수 있다.

주소

메모리 안에 있는 데이터의 위치를 의미하는 값이다.

위치값이 필요한 이유

위치가 어딘지도 모르는데 아무 데나 넣을 수는 없다.

어떤 공간에 값을 넣을 때 그 위치가 어딘지를 알아야 넣을 수 있으므로 우리가 작성한 코드는 특정 주소 위치에 값을 넣도록 명령어가 만들어진다.

또한 변수에 주소를 직접적으로  받아올 수도 있다.

앰퍼샌드(`&`)

`&i`처럼 변수 앞에 `&`를 붙이면 해당 변수의 위치 주소값을 의미한다.

▷ 예시

int main()
{
	int i = 100;
    int* pInt = &i;  // int형 변수 i의 주소를 int 포인터 변수 pInt에 저장한다.
    
    return 0;
}

`main()`가 호출됐을 때 그 스택에 들어있는 `i`의 주소를 `pInt`에 저장한다.

포인터 변수 초기화

// pInt는 int형 변수를 가리키는 int 포인터 변수이다.
// nullptr(null pointer) : 포인터에 null값을 넣어서 초기화한다.
int* pInt = nullptr;

자료형 앞에 `*`가 붙으면 해당 자료형의 포인터 변수가 된다.

`*` 앞에는 그곳을 어떻게 해석하는지에 대한 차이만 있을 뿐, 포인터 변수는 근본적으로 주소를 저장하는 변수이다.

• `nullptr` : 아무것도 가리키지 않는다는 의미로, 실제 들어간 데이터는 0이다.

 주소 자체를 int형으로 보는 것이 아니라 접근한 주소에 해당하는 실제 메모리 공간을 `int`로 해석하겠다는 뜻이다.

포인터 변수를 선언하는 시점에서 타입이 이미 결정이 되어있으므로 포인터 변수 입장에서 실제 공간 안에 있는 데이터는 중요하지 않다.

포인터 변수 초기화할 때 왜 0이나 NULL이 아닌 `nullptr`을 써야 하는 이유

• `ptr`이 있기 때문에 포인터라는 것을 쉽게 알 수 있기 때문에 `NULL`보다 가독성이 좋다.

`0`은 int 타입으로, `NULL`은 상수 0을 뜻하는 `NULL`은 포인터나 int 타입으로 쓰인다.

반면, `nullptr`은 포인터에만 쓸 수 있다.

쓰임이 애매한 `NULL`보다 쓰임이 확실한 `nullptr`을 사용하는 게 더 낫다.

함수 인자로 받을 때 `NULL`은 포인터에서도 쓸 수 있지만 int가 되기도 해서 잘못된 함수가 호출될 수도 있으므로 `nullptr`를 사용한다.

포인터 변수로 변숫값을 수정할 수 있다.

포인터 변수 앞에 `*`을 붙여서 해당 포인터 변수에 저장되어 있는 주소값을 참조할 수 있다.

쉽게 말해서 해당 주소로 접근할 수 있다는 뜻이다.

int main()
{
	int i = 100;
    int* pInt = &i;
    (*pInt) = 200;  // 변수 i의 주소에 접근하여 값을 200으로 변경한다.
    
    return 0;
}

`i`값을 변경하기 위해 `i`의 주소를 저장하는 `pInt`에 `*`로 주소에 접근하여 값을 200으로 수정할 수 있다.

i는 200

포인터형 변수가 데이터를 처리하는 방식은 정수형일까, 실수형일까?

주소의 단위byte이다. (= 주소는 byte 단위로 존재한다.) ※ bit가 아니다!

• 주소를 표현하는 방식은 정수형이다.

bit 단위로는 주소를 공유할 수 없기 때문에 100.5 번지 같은 것이 있을 수가 없다.

 

ex) 100번지와 102번지

100번지와 102번지 사이의 메모리 공간은 2byte만큼의 메모리 공간이 존재한다.

100 101 102

100 ~ 101 = 1byte

101 ~ 102 = 1byte

∴ 2byte

즉, 주소의 차이값 = byte값

주소를 표현하는 방식은 정수형이지만 굳이 앞에 정수 자료형을 적는 이유

단지 주소를 알고 있고 주소를 표현하는 방식이 정수형일 뿐이지, 주소에 얼마만큼의 데이터가 어떻게 있는지는 알 수 없다.

그래서 변수 앞에 `*`을 붙여서 해당 주소로 접근할 때 얼마만큼 접근할 것인지, 찾아간 주소에 어떤 자료형이 있을지는 알 수 없기 때문에 정수형이든 아니든 `*` 앞에 자료형을 꼭 적어줘야 한다.

포인터 변수 앞에 붙은 자료형의 의미

• 포인터 변수가 가리킬 데이터의 타입을 정해놓는다.

답정너처럼 해당 자료형의 변수 주소만 저장한다.

• 주소에 해당 자료형의 크기만큼 접근한다.

  ex) `int*`

    ◦ 포인터 변수가 가리킬 데이터 타입은 `int`이고, 표현 방식은 정수이다.

      +) 정수형과 실수형은 표현 방식 자체가 다르다.

    ◦ int형 포인터 변수는 int형 변수의 주소만 저장한다.

    ◦ 주소에 4byte만큼 접근한다.

▷ 예시

int main()
{
	int i = 100;
    float f = 3.f;
    
    int* pInt = &i;  // int 포인터 변수 pInt에는 int형 변수의 주소만 저장할 수 있다.
    //int* pInt = &f;  // 오류
    
    return 0;
}

`int`와 `float`은 4byte로 크기가 같아도 표현 방식이 다르기 때문에 `int*` 변수에 `float` 변수의 주소를 받을 수 없다.

&f에서 문법 오류

 

아래 코드처럼 강제 캐스팅으로 `f`의 주소를 넣어줄 수는 있다.

int main()
{
	int i = 100;
    float f = 3.f;
    
    int* pInt = (int*)&f;  // float형 변수 f의 주소를 int 포인터로 강제 캐스팅했다.
    i = *pInt;  // 변수 i값 : int의 관점으로 해석한 float형 변수 f의 주소
    
    return 0;
}

▶ 자료형, 변수명, 값을 나타낸 표

ⓐ int형 포인터 변수 `pInt`

자료형 변수명
int* pInt

 

ⓑ int형 변수 `i`에 `100`을, float형 변수 `f`에 `3.f`를 저장한다.

자료형 변수명
int i 100
float f 3.f

 

float형 변수 `f`의 주소를 포인터형 변수 `pInt`에 저장하고, `pInt` 주소에 접근하여 int형 변수 `i`에 저장한다.

int* pInt = (int*)&f;  // float형 변수 f의 주소를 int 포인터로 강제 캐스팅했다.
i = *pInt;  // 변수 i값 : int의 관점으로 해석한 float형 변수 f의 주소

`i`는 int형이니까 아래 표처럼 `3.f`가 아닌 `3`이 들어갈까?

자료형 변수명
int i 3

 

`i`는 int형이라서 `3.f`가 아닌 `3`이 저장될 줄 알았는데, 엄청 큰 숫자가 저장됐다.

i가 1077936128로 나온다.

다른 자료형의 변수의 주소를 강제로 넣었기 때문에 발생한 현상으로, 메모리 안의 데이터와 주소값은 같지만 어떤 관점이냐에 따라서 값이 다르게 해석된다.

다시 말해서, 어떤 포인터냐에 따라 값의 해석이 달라진다.

∴ `3.f` ➜ 실수형 포인터 `float*`의 관점으로는 3.00000000, 정수형 포인터 `int*`의 관점으로는 1077936128로 해석된다.

로컬 창

∴ `3.f`

➜ 실수형 포인터 `float*`의 관점 = 3.00000000

➜ 정수형 포인터 `int*`의 관점 = 1077936128

 

강제 캐스팅을 하기 전에 문법 오류가 났던 이유도 이 때문이다.

`자료형* 변수`에서 자료형은 해당 포인터에게 전달된 주소를 해석하는 단위이다.

그래서 다른 자료형의 주소를 저장하려고 할 때 컴파일러가 문법 오류를 띄워서 실제 목적과 다른 주소를 가리키지 못하게 막은 것이다.


포인터 변수의 크기

포인터 변수 자체의 크기는 자료형의 크기와 상관없고 운영체제, 플랫폼에 따라서 달라진다.

크기는 고정이 아니라 플랫폼에 따른 가변 길이로 계산이 된다.

운영체제 및 플랫폼의 bit 수는 한 번에 어떤 명령을 처리하는 기본 단위(데이터를 처리하는 단위)를 뜻한다.

1) 32bit 운영체제 기반 =  4byte

 4 byte로 표현할 수 있는 가지 수 = 2^32 (약 42억)

4byte로 표현할 수 있는 주소 = 약 42억 번지 (0 ~ 42억 번지)

• 메모리 공간 = 4GB

주소를 4byte 단위로 만들었을 때 표현할 수 있는 주소 번지가 4GB밖에 안 되므로 32bit 운영체제에서는 RAM을 4GB 이상 꽂아도 의미가 없다.

실제 PC에 꽂은 RAM이 16GB라도 그 이상은 주소 번지가 42억이 넘어가버리기 때문에 포인터 변수로 주소를 4GB까지만 가리킬 수 있다.

따라서 메모리 영역 활용이 안 되는 문제가 발생하기 때문에 4byte 공간 변수로 주소값을 만들 수 없다.

 

2) 64bit 운영체제 기반 = 8byte

8byte로 표현할 수 있는 가지 수 = 2^64

 8byte로 표현할 수 있는 주소 = 약 42억^2 번지 (0 ~ 42억 번지)

8byte 공간 변수로는 충분히 주소값을 만들 수 있다.

요즘은 거의 64bit 운영체제를 사용해서 한 번에 처리할 수 있는 8byte이기 때문에 포인터 변수의 크기도 8byte로 알고 있으면 된다.

cpu가 한 번에 4byte 단위로 처리하는 상태인데 운영체제가 8byte 단위로 처리하라는 명령어를 주면 하드웨어가 감당하지 못한다.
반대의 경우, 가능은 하지만 하드웨어의 최대 성능을 끌어내지 못한다.

 

▷ `sizeof()`로 포인터 변수 `pInt`, `pChar`, `pShort`의 크기 구하기

① 64bit 기반

int main()
{
    int* pInt = nullptr;
    char* pChar = nullptr;
    short* pShort = nullptr;
    
	int iSize = sizeof(pInt);  // 8
	char iSize = sizeof(pChar);  // 8
	short iSize = sizeof(pShort);  // 8
    
    return 0;
}

pInt 변수 크기 = 8
pChar 변수 크기 = 8
pShort 변수 크기 = 8

∴ 64bit 기반일 때 포인터 변수의 크기 = 8

 

② 32bit 기반 (솔루션 플랫폼에서 x86으로 변경)

int main()
{
    int* pInt = nullptr;
    char* pChar = nullptr;
    short* pShort = nullptr;
    
	int iSize = sizeof(pInt);  // 4
    int iSize = sizeof(pChar);  // 4
    int iSize = sizeof(pShort);  // 4
    
    return 0;
}

pInt 변수 크기 = 4

∴ 32bit 기반일 때 포인터 변수의 크기 = 4


주소의 증감 단위는 자료형의 크기 단위로 움직인다.

int main()
{
	int* pInt = nullptr;
	int i = 0;
    
    pInt = &i;
    
    return 0;
}

 

int형 변수 `i`의 시작 주소를 100이라고 가정하면 `pInt`도 100을 가리킨다.

변수명
pInt 100

Q. `i`의 끝 주소는?

변수명 시작 주소 끝 주소
i 100 ?

A. 104

주소에서 1의 차이는 1byte를 의미하니까 101일 줄 알았는데 아니다.

Q. 왜 101이 아니라 104일까?

변수명 시작 주소 끝 주소
i 100 104

A. `i`의 크기는 int형이기 때문에 4byte이고, 주소의 단위는 1byte 단위이므로 시작 주소가 100이라면 끝 주소는 104이다.

변수 i의 시작 주소와 끝 주소

 

Q. int 포인터 변수 `pInt`를 한 단계 증가시키면 어떻게 될까?

int main()
{
	int* pInt = nullptr;
	int i = 0;
    
    pInt = &i;
    
    pInt += 1;  // int 포인터 변수 pInt를 한 단계 증가시킨다. = 주소로는 4byte 증가한다.
    
    return 0;
}

A. 104

표현 방식은 정수이지만 포인터(주소)의 연산은 정수의 연산을 따르지 않는다.

주소값에서의 '한 단계 증가'의 의미

포인터 변수 입장에서 한 단계 증가는 단순히 1을 증가시키는 것이 아니다.

• 주소값이 한 단계 증가한다. = 자료형 크기만큼 다음 위치로 접근하기 위해서 `sizeof(자료형)` 단위로 증가한다.

다음 위치에도 해당 자료형의 데이터가 존재한다면 그 자료형의 크기만큼 증가한다.

 

▷ int형 포인터 변수에서의 한 단계 증가

이는 주소로 4byte 증가를 뜻하므로 100이 시작 주소였다면 증가 후 주소는 104가 되는데, 만약 다음 데이터에도 int형 변수가 존재한다면 그것의 시작 주소는 104가 된다.

int main()
{
	int* pInt = nullptr;
	int i = 0;
    
    pInt = &i;
    
    int isize = sizeof(int);  // 4   
    
    // pInt의 다음 위치로 접근하기 위해 sizeof(int) 단위로 증가한다.
    pInt += 1;
    
    return 0;
}

 

다른 자료형들도 마찬가지이다.

char형 주소 ➜ 1byte

short형 주소 ➜ 2byte

주소의 증감 단위는 자료형 크기 단위로 움직인다.


포인터 배열

배열을 제대로 알려면 포인터를 알아야 되는데, 그 이유는 포인터 배열을 통해 알아보자.

배열의 특징

• 메모리가 연속적인 구조이다.

 배열의 이름은 배열의 시작 주소이다.

원하는 특정 요소를 포인터를 통해 접근하는 법 

`iArr`은 배열 이름이면서 배열의 시작 주소이다.

int 배열 주소이므로 주소의 단위도 int이다.

// iArr은 4byte 단위로 10묶음인 int 배열이다.
int iArr[10] = {};

// int 배열이라서 int 포인터 타입으로 보기 때문에 정확한 문법은 아래 코드와 같다.
int(&iArr)[10] = iArr;

10개의 칸 중에 두 번째 칸에 10을 넣고 싶을 때

iArr 메모리

 

`iArr`의 주소에 1을 더하면 실제 크기가 4byte이기 때문에 4byte가 더해진다.

int main()
{
	int iArr[10] = {};
    
    iArr + 1;  // 실제로는 주소에 4가 더해진다.
    
    return 0;
}

아래 그림의 노란색 점은 시작 주소로부터 4byte를 더한 주소를 의미한다.

iArr 메모리

 

주소 앞에 `*`를 붙이면 주소값으로 접근한다는 의미이고, 접근은 해당 포인터 변수가 어떤 것인지에 따라 달라진다.

예시의 경우 int 배열의 주소이기 때문에 시작으로부터 한 칸 띄운 곳에 접근하는 단위는 int이다.

배열이 시작 위치로부터 한 칸 띄운 위치에 접근하여 10을 넣게 되면 두 번째 칸에 10이 저장된다.

int main()
{
	int iArr[10] = {};
    
    // int 단위로 접근
    *(iArr + 1) = 10;
    
    return 0;
}

두 번째 칸에 10이 저장되어 있다.

두 번째 칸에 10을 넣었다.

첫 번째 칸에 10을 넣고 싶을 때

배열의 이름이면서 배열의 시작 주소인 `iArr`은 첫 번째 데이터의 주소이기 때문에 시작 주소로부터 얼마를 더할 필요가 없다.

배열의 첫 번째 index가 0인 이유이기도 하다.

*(iArr + 0) = 10;  // 첫 번째 칸
*(iArr + 1) = 10;  // 두 번째 칸

주소 연산 방식을 축약으로 표현한 연산자

• 배열 연산자 `[ ]` = `*(iArr + 0)`

// 배열이름[인덱스]의 실제 표현 수식은 *(배열이름 + 인덱스)이다.
iArr[0] = 10;  // *(iArr + 0) = 10;
iArr[1] = 10;  // *(iArr + 1) = 10;

 

`*(배열이름 + 인덱스)`을 간단하게 표현한 수식이 `배열이름[인덱스]`이다.

특정 index를 접근한다. = 주소를 접근한다.

배열에서 포인터를 언급했던 이유이기도 하다.

오프셋(offset) 이용해서 이해하기

• 오프셋(offset) : 시작으로부터 떨어진 거리

1) `iArr[0]`

배열의 이름은 시작 주소이므로 그곳으로부터의 오프셋은 0이다.

*(iArr + 0) = 10;

`iArr` : 배열의 시작으로부터

`+ 0` : 오프셋 0 = 0칸 떨어진 곳에 = 시작 지점에

`*` : 접근해서

`= 10` : 10을 넣어라.

 

2) `iArr[1]`

*(iArr + 1) = 10;

`iArr` : 배열의 시작으로부터

`+ 1` : 오프셋 1 = 한 칸 떨어진 곳에 = (int가 4byte이기 때문에) 4 떨어진 곳으로

`*` : 접근해서

`= 10` : 10을 넣어라.