[Rookiss C++] 포인터의 활용

황교선·2023년 3월 22일
0

cpp

목록 보기
12/19

구조체와 포인터

일반 자료형처럼 구조체도 메모리에 할당되기 때문에 포인터로 그 구조체의 값을 읽고 쓸 수 있다.

struct A
{
    int x;
};

int main()
{
    A st1 = { 3 };
    A* st2 = &st1;
}

이렇게 구조체도 별다르지 않게 구조체 자료형 뒤로 *를 붙여 포인터로 만들 수 있다. 하지만 어떻게 멤버에 접근해야하는가?

멤버 엑세스 연산자

우리는 기존의 멤버 엑세스 연산자로 . 을 배웠다. 이를 사용하면 될 것 같긴하지만 포인터랑 같이 사용할 때는 주의해야한다.

(*구조체포인터).멤버이름
구조체포인터->멤버이름

우선 첫 번째 줄을 보자. 연산자의 우선순위에서 . 의 연산이 *(포인터)의 연산보다 하나 위의 우선순위를 갖기 때문에 ( ) 가 없으면 먼저 . 가 실행된다. 즉, 괄호가 없다면 1. 구조체포인터를 구조체라고 간주하고 멤버 연산자를 수행하며 2. 그 멤버변수를 주소처럼 생각하고 * 로 참조한다라는 이상한 문법이 되어버리며, 컴파일 오류가 난다.

우리는 1. 구조체포인터에 있는 주소를 찾아가서 2. 그 안에 있는 멤버 변수에 접근하고 싶기 때문에 현재 문법에서 연산자 우선순위에 역순으로 실행해야하므로 *구조체포인터를 괄호로 감싸서 먼저 실행되게 한다. 그리고 멤버 연산자로 멤버를 참조하여 읽고 쓸 수 있게된다.

첫 번째 방식도 올바르긴 하지만 ( ) 와 *, .를 항상 써야하므로 아래와 같은 두 번째 방식이 있다. 위의 문법과 아래 문법은 똑같이 수행된다.

struct A
{
    int x;
};

int main()
{
    A st1 = { 3 };
    A* st2 = &st1;

    cout << (*st2).x << endl;
    cout << st2->x << endl;

    (*st2).x = 7;
    cout << st1.x << endl;
    st2->x = 10;
    cout << st1.x << endl;
}
// 출력 결과
// 3
// 3
// 7
// 10

함수에서의 활용

함수가 호출될 때 매개변수들은 함수의 새로운 지역변수로 생성되면서 값이 복사되고 원본이 아닌 그 지역변수가 읽고 써지면서 값이 수정되기도 한다. 그리고 함수가 종료되면 지역변수가 사라지며, 원본은 값이 변경되어 있지 않고 그대로 보존되어 있다. 그렇다면 포인터의 주소값을 복사하여서 함수 내에서 그 주소값을 찾아가서 값을 수정하는 방식이 되지 않을까 라는 의문이 든다. 이는 실제로도 이렇게 동작하고 다음과 같은 코드를 보자.

값 전달 방식

struct Player
{
    int hp;
    int damage;
};

Player CreatePlayer(); // 값 전달 방식

int main()
{
    Player player1;
    player1.hp = 100;
    player1.damage = 10;
    player1 = CreatePlayer();
}

Player CreatePlayer() // 값 전달 방식
{
    Player ret;
    ret.hp = 120;
    ret.damage = 20;

    return ret;
}

Player 구조체를 만들었고 이 구조체의 멤버로 정수 변수 두 개가 선언되어 있다. 또한 CreatePlayer라는 함수를 선언하고, 이 함수는 Player 구조체를 반환한다.

CreatePlayer 함수 내부에서 Player 구조체를 하나 생성하고, hp, damage 멤버에 각각의 값을 대입한 후 이 구조체를 반환한다. 이 동작을 좀 더 자세히 보면, 반환값 자체도 지역변수처럼 생성되는듯 싶다

강의 중의 내용이고 StatInfo 구조체를 Player 구조체라고 생각하고 보면 될 것 같다. 결국에는 구조체 자체를 보기 위해서 하는거라 뭐 큰 차이는 없다. 주석에 써있는 내용 중 main 함수에는 temp라는 지역변수가 하나 만들어지고 CreatePlayer 함수에서는 지역변수 ret도 지역변수로 만들어진다. 그리고 함수 내부에서는 ret에 값을 하나 하나 넣어준 후 이 ret 의 값은 main 함수의 temp라는 방금 생긴 지역변수에 값이 복사된다. 그리고 나서 temp에서 또 player 라는 구조체로 값이 또 복사된다.

요약하자면 CreatePlayer 함수 내의 ret 구조체 변수 → main 함수에 임시로 생긴 temp → player 로 두 번 값이 복사된다. 구조체의 크기가 클수록(멤버 변수가 많을수록) 각 멤버로의 복사하는 비용이 증가한다는 것으로 비효율적인 방식이라고도 볼 수도 있겠다.

주소 전달 방식

struct Player
{
    int hp;
    int damage;
};

void CreatePlayer(Player* player); // 주소 전달 방식

int main()
{
    Player player2;
    player2.hp = 130;
    player2.damage = 30;
    CreatePlayer(&player2);
}

void CreatePlayer(Player* player) // 주소 전달 방식
{
    player->hp = 140;
    player->damage = 40;
}

다음은 주소 전달 방식이다. 매개변수로 구조체의 포인터만 있으므로 구조체의 크기(int, int = 8byte)가 아닌 주소(* = 8byte)만 필요하다. 물론 현재로서는 구조체의 크기가 주소 체계의 크기와 일치하여 차이가 아예 없지만 만약 구조체가 int 멤버 변수 100개가 존재한다할 때 400Byte 이므로 392Byte의 차이가 날 것이다.

함수가 호출될 때 포인터 자료형 매개변수 하나만 생성되고, 이 변수 안에 들어잇는 주소를 찾아가 원본의 데이터를 변경하고 함수가 종료된다. 원본을 건들이기에 값 복사에 대한 연산이 이루어지지 않아 효율적이라고 바라볼 수 있지만, 원본을 건드는 것이기 때문에 위험요소가 있다.

void TestArgumentAddr(int* ptr)
{
    cout << &ptr << endl; // 포인터 자체의 주소 : int*의 주소
    cout << ptr << endl; // 포인터가 갖고 있는 주소값 : int*
    cout << *ptr << endl; // 포인터가 갖고 있는 주소값을 찾아갔을 때의 값 : int
}

int main()
{
    int num = 5;
    int* ptr = &num;

    cout << &ptr << endl; // 포인터 자체의 주소 : int*의 주소
    cout << ptr << endl; // 포인터가 갖고 있는 주소값 : int*
    cout << *ptr << "\n\n"; // 포인터가 갖고 있는 주소값을 찾아갔을 때의 값 : int
    
    TestArgumentAddr(ptr);
}
// 출력 결과
// 0x16bb6f250
// 0x16bb6f25c
// 5

// 0x16bb6f228
// 0x16bb6f25c
// 5

// 두 포인터 변수가 갖고 있는 값은 같지만, 
// main 함수 내의 지역변수 포인터의 주소와 함수 내의 매개변수 포인터의 주소는 서로 다른 것을 볼 수 있음

주소 전달 방식도 사실 지역 변수가 생성되어 그 지역 변수에 원본이 갖고 있는 주소를 전달해주는 것(이 말 자체가 주소 전달 방식이지만 좀 더 의미를 파악해보고자 쓰는 것) 뿐이다.

  1. 포인터를 다루는 지역 변수(매개변수)를 생성
  2. 생성된 지역 변수에 인자로 들어온 원본에 담겨 있는 값(주소)를 복사
  3. 함수 내에서 이 지역 변수가 갖고 있는 주소값을 다룸

결국 지역 변수에 원본 값을 복사하여 사용하는 것이지만 복사된 값이 주소라는 값이기 때문에 원본에 접근할 수 있게 되는 것

참조 전달 방식

포인터를 다룰 때, 주소를 잘못 건드릴 수도 있기 때문에 사용할 수 있는 방식이다. 매개변수의 자료형 옆에 &를 붙여서 사용하면 된다. 이는 원본의 값을 사용하는데 함수 내에서는 지역 변수처럼 문법을 사용한다.

참조 전달 방식은 어셈블리 레벨까지 들어가면 주소 전달 방식과 동일하게 작동한다.

struct Player
{
    int hp;
    int damage;
};

void CreatePlayer(Player& player); // 참조 전달 방식

int main()
{
    Player player3;
    player3.hp = 150;
    player3.damage = 50;
    CreatePlayer(player3);
}

void CreatePlayer(Player& player) // 참조 전달 방식
{
    player.hp = 160;
    player.damage = 60;
}

이처럼 일반 지역변수처럼 사용하는 느낌을 줄 수 있어서 편리하지만 지역변수라고 생각할 수 있는 착각 때문에 원본을 변경했다는 것을 깜빡할 수 있기 때문에 조심해야한다.

주소 전달 방식 vs 참조 전달 방식

두 방식 모두 원본을 사용하는 것이기 때문에 무엇을 선택하든 상관없지만 여러 예시를 보고 따라 쓰자.

  1. team by team
  2. 구글 오픈소스에서는 포인터를 사용
  3. 언리얼 엔진에서 참조도 사용
  4. Rookiss(강사) 방식
    1. 주소가 없는 경우도 고려해야한다면 포인터
    2. 바뀌지 않고 읽는 용도로는 const
    3. 다른 사람이 포인터로 사용하고 있었으면 포인터로 통일

const 키워드의 활용

// 값 변경 불가
void CreatePlayer(const Player* player)
{
    Player temp;
    player = &temp; // 가능

    cout << player->hp; // 읽는 것은 가능
    // (*player).hp = 140; // 쓰는 것은 불가
    // player->damage = 40; // 쓰는 것은 불가

}

// 주소 변경 불가
void CreatePlayer(Player* const player)
{
    Player temp;
    // player = &temp; // 불가

    (*player).hp = 140; // 읽고 쓰기 가능
    player->damage = 40; // 읽고 쓰기 가능
}

// 값 변경 불가
// 주소 변경 불가
void CreatePlayer(const Player* const player)
{
    Player temp;
    // player = &temp; // 불가

    cout << player->hp; // 읽는 것은 가능
    // player->hp = 140; // 불가
    // player->damage = 40; // 불가
}

포인터를 활용하기에 원본의 값에 접근하지만 제약을 걸 수 있는 const와의 활용 예시이다.

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

0개의 댓글