[Rookiss C++] 포인터

황교선·2023년 3월 21일
0

cpp

목록 보기
11/19

함수와 스택 프레임 글에서 메모리와 메모리의 영역 중 스택 영역을 얘기할 때 주소에 대한 설명이 조금 나왔었다.

메모리의 어떤 공간에 어떤 변수가 있는지를 기억하기 위해서는 주소, 우리의 집이 어디있는지를 알 수 있는 그런 주소처럼 각 변수는 메모리의 어떤 공간에 있을 것이고 그 공간에는 주소가 있다.

우리가 여태 자주 사용한 변수들도 어떠한 주소에 자료형 크기만큼의 공간을 배정 받은 것이다. 이러한 주소 자체도 변수에 넣어서 다루기 위하여 주소를 담는 변수인 포인터가 있다.

포인터

메모리에 공간을 할당받은 변수의 주소값을 저장하는 변수

포인터 연산자

포인터와 연관되어 사용되는 연산자는 두 가지다.

  • &, 주소 연산자
  • *, 참조 연산자

포인터는 주소를 담기 위해 있는 변수이니 우선 주소 연산자가 있어야 어떠한 변수의 주소를 알 수 있을 것이다. 그렇게 알아낸 주소를 가지고 그 주소로 찾아가 그 안에 있는 데이터를 다룰 수 있게 하는 참조 연산자도 있다.

주소 연산자

&
변수의 주소를 추출할 수 있음

&변수 // 변수의 주소값이 반환됨
void test()
{
    int x = 10;
    int y = 20;
    int z = 30;
    cout << &x <<  endl;
    cout << &y <<  endl;
    cout << &z <<  endl;
}

int main() 
{
    int a = 10;
    int b = 20;
    int c = 30;
    cout << &a <<  endl;
    cout << &b <<  endl;
    cout << &c <<  endl;
    test();
}
// 출력 결과
// 0x16b85324c
// 0x16b853248
// 0x16b853244
// 0x16b85320c
// 0x16b853208
// 0x16b853204

출력 결과의 위의 세 줄은 main 함수 a, b, c 정수 변수의 주소값이고, 아래 세 줄은 test 함수의 x, y, z 정수 변수의 주소값이다. 이 주소값은 컴퓨터마다 다르고, 프로그램의 실행 타이밍에 따라 다르다. 컴퓨터마다 메모리의 전체 크기는 다를 것이고, 운영체제 또한 달라 메모리를 관리하는 방법이 다르고, 같은 운영체제에 같은 메모리라해도 현재 메모리에 적재되어 있는 프로그램의 목록에 따라 할당하는 곳이 다르다.

참조 연산자

  • 포인터(주소) 변수에 담겨있는 주소로 이동하여 그 공간에 담긴 데이터를 알 수 있음

이 참조 연산자와 헷갈리는 것이 포인터를 선언할 때 자료형 옆에 사용하는 *와 곱셈에서 사용하는 *이다. 물론 포인터 자료형을 선언할 때의 *와 더 헷갈리고 이를 잘 구분만 한다면 어렵지 않다.

포인터 변수를 선언할 때의 * : 주소를 담는 변수를 선언하라
포인터 변수에 사용할 때의 * : 변수에 담겨 있는 주소로 찾아가라

자료형* 변수명; // 포인터 변수 선언
*변수명; // 포인터 변수에 담겨있는 주소로 찾아가라
int intVar = 10;
int* pointer = &intVar; // *가 자료형 옆에 있으니 포인터 변수를 선언하는 데의 역할을 함

cout << intVar << endl; // 10
cout << *pointer << endl; // 10, 자료형이 없으니 참조 연산자로 사용되는 *, pointer라는 포인터 변수에 담겨있는 주소(intVar의 주소)로 찾아가서 그 값을 출력함
*pointer = 20; // 자료형이 없으니 참조 연산자로 사용되는 *, pointer라는 포인터 변수에 담겨있는 주소(intVar의 주소)로 찾아가서 20을 대입함, intVar의 값이 20으로 바뀜
cout << intVar << endl; // 20
cout << *pointer << endl; // 20

포인터 변수 앞에 자료형을 붙이는 이유

메모리의 주소값을 저장하는 변수이니 그냥 * 변수명 같은 간단한 선언 형식으로 바꿔도 되지 않을까라는 의문이 든다. 하지만 여러가지 이유가 있다.

  • 다른 자료형의 주소를 잘못 담을 때의 실수를 막아줌
  • 포인터 변수의 증감 연산의 차이점
  • 포인터 변수의 선언이 참조 연산자와 구분이 잘 안 됨(따로 설명은 안함)

다른 자료형의 주소를 잘못 담을 때의 실수를 막아줌

주소를 다루는 일은 까다롭다. 자칫하면 프로그램이 다운되고, 예상치 못한 오류가 발생하고, 원치 않는 데이터의 변환을 초래할 수 있다. 그렇지만 활용성이 엄청 높기 때문에 잘 사용하면 좋다. 그런 예상치 못한 오류를 어느정도 막아줄 수 있게 하기 위하여 포인터 변수 앞에 자료형을 붙인다.

int main() 
{
    cout << hex; // 출력값을 16진법으로 바꿔줌

    int num = 5;
    int* ptr = &num;

    __int64_t* ptr2 = (__int64_t*)ptr;
    *ptr2 = 0x0000AABBCCDDEEFF; //ptr2가 갖고 있는 주소의 값에 8바이트짜리 값을 대입
                                //ptr은 4바이트공간의 변수인데 그 넘어 주소까지 0000AABB가 씌워져서 위험함
    
    cout << *ptr << endl;       // ccddeeff
    *ptr = 0x0A0B0C0D;          //
    cout << *ptr << endl;       // a0b0c0d
    cout << *(ptr+1) << endl;   // aabb
}
// 출력 결과
// ccddeeff
// a0b0c0d
// aabb

  1. 메모리는 익스텐션을 깔아서 보았다.
    num이라는 변수가 선언되었고, 이 변수의 주소값이 0x000000016fdff390이다.
    이 num의 주소를 ptr과 ptr2의 값으로 대입하였다.
    현재 주소가 가르키고 있는 곳 4칸(int는 4바이트)을 보면 05 00 00 00(리틀 엔디언이라 역순)이 차있다.

  1. 12번째 라인을 보면 4바이트의 자료형이 아닌 8바이트 자료형 int64 에 대한 포인터를 선언하고, 4바이트 자료형 int의 포인터 변수의 값을 넣었다. 이 말인 즉슨 ptr2가 갖고 있는 주소에 들어있는 값을 int64처럼 읽고 쓰겠다는 말이 되는 것이다. (__int64_t*)라는 명시적 형변환이 없다면 컴파일 에러가 난다. 현재 포인터의 자료형이 다르니 오류가 났다는 것이고 즉, 내가 포인터의 형변환을 하고 있다는 것을 알고 있고 이 위험을 잘 인지하고 있다는 것을 내포하고 있는 것이다.

    13번째 라인을 보면 이 ptr2가 가르키고 있는 곳에 8바이트 크기의 값을 거의 가득 채워서 넣었다. 4바이트로는 이 값을 받아들이지 못하고, ff ee dd cc를 넘어서까지 값을 집어넣어서 메모리 창을 보면 bb aa 00 00까지 값이 들어간 것을 볼 수 있다. 8바이트의 큰 상수 값이 아니더라도 남는 자리는 결국 0으로 채워짐으로 결국에는 4바이트 값을 넘어서의 주소까지 건들인 모습이다.

  1. 2번째까지가 말하고 싶은 요점인 타입 불일치에 대해서 설명했지만, 추가적으로 다시 기존 포인터(4바이트 자료형의 포인터 변수)를 건들였을 때를 보자.

    기존 포인터는 8바이트가 아닌 4바이트의 자료형의 주소를 갖는 변수이기 때문에, 포인터가 가르키고 있는 곳에 값을 대입하면 bb aa 00 00은 0으로 초기화가 되지 않고, 0d 0c 0b 0a만 들어간 것을 볼 수 있다. 중간에 8바이트 자료형 포인터로 값의 대입을 하여서 4바이트를 넘어버렸었지만, 다시 4바이트 자료형 포인터로 가르키는 곳에 값을 넣을 때는 이 포인터가 선언될 때의 자료형을 기준으로 값이 들어가는 것을 볼 수 있다.

이처럼 현재 내가 다루고 싶은 변수의 자료형에 맞춰 포인터도 자료형을 맞춰야 오류를 적게 낼 수 있게 된다. 혹은 명시적 형변환을 통하여 현재하고 있는 일이 무엇인지 명확히 알고 간다는 문법을 통하여 사용할 수도 있다.

포인터 변수의 증감 연산의 차이점

포인터에 자료형을 붙이는 두 번째 이유는 증감 연산에서 수의 값이 포인터의 자료형에 맞게 연산이 되기 때문이다.

int a = 0;
a++; // a = 1
a + 2; // a = 3
// 기존의 증감 연산은 더한만큼 값이 커짐
int main() 
{
    cout << hex; // 출력값을 16진법으로 바꿔줌

    int num = 5;
    int* ptr = &num;

    cout << ptr << endl;
    ptr += 1;
    cout << ptr << endl;
}
// 출력 결과
// 0x16fdff39c
// 0x16fdff3a0

출력 결과를 보면 9c 다음으로 9d가 나와야하지만 그보다 4칸 9d, 9e, 9f을 건너뛴 a0이 출력되는 것을 볼 수 있다. 각 주소는 1byte 크기이므로 4칸인 4byte가 건너뛰어진 것이고, 이는 포인터의 자료형 int의 크기와 일치한다.

포인터에서의 증감의 단위는 각 자료형의 크기만큼 계산이 된다는 소리다. 이를 편하게 사용하는 개념은 배열이지만 여기서 말고 배열을 배울 때 설명한다.

int main() 
{
    cout << hex; // 출력값을 16진법으로 바꿔줌

    short sh = 123;
    short* sPtr = &sh;
    cout << sPtr << ", " << sPtr + 1 << endl;

    int64_t lo = 123789LL;
    int64_t *lPtr = &lo;
    cout << lPtr << ", " << lPtr + 1 << endl;
}
// 출력 결과
// 0x16b02b24e, 0x16b02b250 // 4e에서 2바이트 넘은 50 // 4f 50
// 0x16b02b238, 0x16b02b240 // 38에서 8바이트 넘은 40 // 39 3a 3b 3c 3d 3e 3f 40

다중 포인터

포인터는 어떠한 변수의 주소값을 담는 변수라고 했었다. 그렇다면 주소 담는 변수의 주소값을 담을 수는 없을까?

메모리에 공간을 할당받은 변수의 주소값을 저장하는 변수

이전 글에서 포인터의 정의를 이렇게 내리긴했지만 이중 포인터의 정의를 좀 더 쉽게 설명하기 위해 다시 정의해보자

포인터

주소를 담는 변수

이중 포인터

주소를 담는 변수의 주소를 담는 변수

이중 포인터의 정의는 위와 같으므로 이중 포인터의 다음인 삼중 포인터는 이중 포인터의 주소를 담는 변수가 될 것이므로 다중 포인터는 재귀적으로 이전 차원의 포인터 주소를 담는 변수가 될 것이다.

int a = 10;

int *p = &a; // 정수의 주소를 담음

int** pp = &p; // 주소를 담는 변수(p)의 주소를 담음

*p // p에 들어있는 주소값을 찾아가 그 주소값을 읽고 쓸 수 있음
**p // p에 들어있는 주소값을 찾아가 그 주소값을 또 찾아간 후 int 값을 읽고 쓸 수 있음

간단하게 이중포인터로 할 수 있는 연산을 적긴했지만, 현재 이중포인터를 활용하려면 동적할당이라는 것을 배워야 좋기 때문에 동적할당 부분에서 이중 포인터의 예제를 담도록 하겠다.

profile
성장과 성공, 그 사이 어딘가

0개의 댓글