C++ 함수 포인터

200원짜리개발자·2023년 6월 27일
0

C++

목록 보기
18/39
post-thumbnail

강의 보고 공부한 것을 정리하는 목적으로 작성된 글이므로 틀린 점이 있을 수 있음에 양해 부탁드립니다. (피드백 환영입니다)

왜 필요한가?

우리가 저번에 사용했었던 우선순위 큐를 정의할 때
대소비교를 바꾸는데 사용하는 greater와 less가 함수 객체이다.

함수 객체를 이해하기 위해서는 함수 포인터를 알아야 하기 때문에
함수 포인터를 공부해보겠다.

함수 포인터란?

포인터는 어떤 변수가 있는 상태에서 그 변수의 주소값을 담는 바구니이고
앞에 자료형은 이 주소값을 타고 갔을 때 무슨 자료형인지를 주장하는 것이였다.

이것이 포인터의 개념이였다.

우리가 이것을 조금 분리해서 만들어본다면

int a = 10;
//typedef int DataType;
using DataType = int;

DataType* ptr = &a;

이런식으로 할 수 있을 것이다.

int, double, float 이런 자료형 뿐만 아니라 함수도 포인터로 가지고 있을 수 있다.

문법

그럼 문법을 간단하게 살펴보자

void Print()
{
	cout << "Hello" << endl;
}

int main()
{   
    //typedef void FuncType();
	using FuncType = void();
}

이런식으로 만들어 줄 수 있다.

using 문법을 사용시에
함수 이름을 아무렇게 만들어서 넣어주고
뒤에 반환값과 인자들을 넣어주면 된다.

사용은
FuncType* ptr = &Print;
이런식으로 사용할 수 있다. (&생략가능)

그리고
ptr();이런식으로 함수를 호출해줄 수 있다.

나중에 함수의 타입이 달라진다면

int Add(int a, int b)
{
	return a + b;
}

int main()
{   
    //typedef void FuncType();
	using FuncType = void();
    FuncType* ptr = &Add // 안됨..
}

이런식으로 시그니처가 달라서 저장이 되지 않는다.
즉 우리가 void(); 이렇게 하였기 때문에 void에 인자가 없는 시그니처를 가진 함수들만 저장이 될 수가 있는 것이다.

그러면 Add를 넣기위해서는 우리는 void();를 int(int, int);로 바꿔주면 Add를 넣어줄 수 있다.

int Add(int a, int b)
{
	return a + b;
}

int main()
{   
    //typedef void FuncType();
	using FuncType = int(int, int);
    FuncType* ptr = &Add //됨!
    
    int abc = ptr(1, 2);
}

이런식으로 똑같이 시그니처를 맞춰주면 된다.

하지만 솔직히 함수자체를 타입으로 만들어서 포인터로 만들어 사용하는 방식은 잘 사용되지 않는다.

우리는 함수포인터를 한 번에 만들기 위해서 무엇을 해야하는냐?

void Print();
일단 이런식으로 함수의 원형을 적어준다.

void(*FuncPtrType)();
그리고 이런식으로 바꿔 줄 수 있다.

이렇게 사용한다면 바로 FuncPtrType에 함수를 담아줄 수 있게 된다.
FuncPtrType = &Print;

하지만 이 문법은 보기(가독성 안 좋음)가 굉장히 어렵다.(옛날 문법)
그래서 현대적인 문법으로 만들어보면

using FuncPtrType = void(*)();
이런식으로 사용할 수 있다.

즉, void를 반환해주고, void를 받아주고(인자 없음), 함수의 포인터 타입을 FuncPtrType이라고 부르겠다!라고 읽어주면 된다.

그럼 단계별로 나눠서 알아보자

  1. 함수 원형을 적어준다. void Print();
  2. 함수 이름을 지워주고 (*)을 넣어준다. void(*)();
  3. using 블라블라를 추가 해준다. using brabra = void(*)();

이렇게 3단계로 나눠질 수 있다.

의문점

왜 이짓거리를 하는거지? 라는 생각이 들 수 있다.
그냥 Print()쓰면 되지 왜 굳이 저런식으로 사용하는거지?
이렇게 단계별로 하는것이 무슨 의미가 있을까?

과연 어느 상황에서 함수를 포인터로 만들어서 관리를 해야하는가를 생각 해봐야 한다.

우리가 지금까지는 데이터를 변수로 만들어서 넘겼지만
'행동'자체를 넘기고 싶을 때가 있을 수 있다.

예시

만약 우리가 DoSomething이라는 함수가 있을 때

using Func = int(*)(int a, int b);

void DoSomething(int a, int b, Func func)
{
	return func(a, b);
}

이런식으로 사용할 수 있게 된다.

그래서 동작(함수)자체를 바꿔준다면 다르게 동작할 수 있게 만들어 줄 수 있다.

DoSomething(10, 20, &Add)
이런식으로 말이다.

근데 이러면 결국 Add라는 함수를 만들어줘야 되지 않는가? 할 수 있는데
나중에 우리가 람다식같은 것을 배우면 다양하게 사용할 수 있게 될 것이다.

그래서 결국에는 동작하나를 넣어줘서 추가적으로 다르게 동작하는 것을 만들고 싶을 때 유용하다고 볼 수 있다.

또 다른 예시를 들어본다면,
콜백함수를 예로 들어볼 수 있다.
콜백함수는 어떤 행동을 하였을 때 역으로 함수를 호출을 해주는 것인데

예시로 UI에서 우리가 어떤 버튼을 누르면 기능이 호출되어야 하는데
게임 엔진쪽에서는 어느 함수가 호출되어야 하는지 전혀 모른다.
그럴 때 우리가 함수를 맵핑해주어서 UI를 눌렀을 때 어떤함수가 실행되게 연결 해줄 수 있다.

이럴 때도 어떠한 동작을 인자로 넘겨줘야 할 수 있다.

그리고 설계할 때도 이러한 일이 많이 발생한다.
만약 우리가 온라인게임을 만들 때 클라가 입장을 하였을시에 서버에서 어떠한 행동을 해야하는데 이런 행동같은 것을 함수 포인터로 받아 연결을 해주어 처리를 할 수도 있다.

그리고 키보드 입력같은 것을 할 때 특정키를 입력시, 롤을 예시로 들면 Q를 누르면 Q스킬이 나간다. 이런식으로 해줘야 했을 때

우리가

if(Q버튼 누름)
	Q스킬();

이런식으로 만든다면 키를 커스터마이징을 하였을 때 처리가 힘들어 질 수 있다.
여기서 함수 포인터를 사용한다면,

using OnClickKeyboard = void(*)();

OnClickKeyboard qSkill;

qSkill();

실행하는 함수자체를 저장하고 실행하는 형식으로 바꿔주면 얼마든지 우리가 바꿔주기 편할 수 있다.

마지막 케이스는
클라이언트에서 인벤토리 작업을 하는 중에
아이템이라는 클래스에 다양한 정보들이 있는데

만약 아이템의 희귀도, 주인, id를 찾기 기능을 만든다고 치면
너무 많은 함수가 나오게 될 것이다.

이럴 때 어차피 아이템은 모든 아이템을 순회해서 찾기 때문에
Item* findItem이라는 함수를 하나 만들고 itemCount만큼 반복하는 코드를 작성후에 Item을 받고 조건을 주는데 이 조건을 우리가 받은 함수인자로 결정을하여서 return을 시킬 수 있게 만들어 줄 수 있다. (아직 람다식을 배우지 않아서 함수를 만들어야 한다.)

class Item
{
public:

publuc:
	int _itemid = 0;
    int _rarity = 0;
    int _ownerid = 0;
};

using ItemSelectorType = bool(*)(Item* item);

Item* FindItem(Item items[], int itemCount, ItemSelectorType selector)
{
	for( int i = 0; i < itemCount; i++)
    {
    	Item* item = &Items[i];
        if(selector(item))
        	return item;
    }
    
    return nullptr;
}

bool IsRare(Item* item)
{
	return item->_rarity == 1;
}

int main()
{
	Item items[10];
    items[3]._rarity = 1; // RARE
    
    FindItem(items, 10, isRare);
}

이런식으로 만들어서 사용할 수 있다.

응용하여서 MMO에서는
서칭을 하여 몬스터를 찾은다음에 특정 조건에 의해 몬스터를 칠 때
몬스터 우선순위 (보스 > 몬스터 > 중립 몬스터) 이런식의 조건을 거는 것이 많다면
조건들을 따로 빼서 사용하면 편리하게 사용할 수 있다.

즉 우리는 동작이라는 것도 인자로 넘겨주면 유용하고 위와 같은 상황을 기억하여서 활용해보는 것이 중요하다.

멤버 함수 포인터

멤버함수어떤 클래스안에 존재하는 함수들이다.
멤버함수가 아닌 함수들은 전역함수와 static(정적)함수이다.

전역함수와 static함수는 앞에서 배운 문법을 활용하면 되지만
멤버함수는 조금 달라진다.

전에 우리는 using FuncPtrType = void(*)();
이런식으로 선언을 해주고
FuncPtrType func = &Print();
이런식으로 넣어줄 수 있었지만
멤버 함수는 이런식으로 되지 않는다.

왜 넣어지지 않느냐? 하면
함수 호출 규약이 다르기 때문이다.

함수 호출 규약이란?

함수를 호출할 때 전달되는 인자들의 순서나
함수가 종료될 때 누가 정리를 할 것인지

이러한 것이 함수 호출 규약이다.

일반 함수는 cdecl이라는 호출 규약을 사용하고
멤버 함수는 thiscall이라는 호출 규약을 사용한다.

멤버함수는 어떤 객체를 대상으로 실행하는 것(객체에 종속적)이기에 함수를 실행할 때 그 객체의 주소도 인자로 받는다.

그래서 결과적으로는 호출 규약이 맞지 않아서 넣어지지 않는다고 볼 수 있다.

그럼 멤버함수를 포인터로 만들고 싶다면 어떻게 해야할까?

(Test라는 클래스가 있다고 가정)

using MemFuncPtrType = void(Test::*)();

MemFuncPtrType funcPtr = &Test::PrintTest;

Test t;
(t.*funcPtr)();

Test* t2 = &t;
(t2->*funcPtr)();

이런식으로 문법이 참 괴랄하게 되어 있다..

어차피 별로 안쓰긴하지만 언젠가 한 번 사용할 일이 있을 수 있기때문에
어떤식으로 생겼는지만 기억을 좀 하고 넘어가자.

예시로 들자면
서버에서 이런식으로 사용할 일이 생기게 되는데
함수 포인터 함수 객체가 유용하게 활용이 되는데

함수 포인터 사용시 많은 장점이 있다고 하였지만 근본적으로는
함수를 실행하는 시점을 뒤로 미룰 수 있다는 점이 매우 좋다.

당장 Print를 넣어줘도 나중에 시간이 날 때 실행을 시켜줄 수 있기 때문이다.

이러한 것들은 나중에 배우는 커맨드패턴에 좋은 사용 예제라고 볼 수 있다.

만약 우리가 스타벅스에서 커피를 시킨다고 하면
사람이 별로 없다면 바로 제조를 해서 커피를 받을 수 있지만
사람이 많아서 주문이 밀린다면 바로 제조를 못하고 주문 번호를 받아서 차례가 되면 커피를 받을 수 있다.

이런 것처럼 내가 요청을 한 것과 처리하는 부분을 분리해서 만들게 된다면
클라에서 10번 공격
클라2에서 20번으로 이동
이런식으로 요청이 왔을 때

이러한 것들을 한 번에 실행해 줄 수 없기 떄문에 주문서를 만들어서 순서대로 실행시킬 수 있다.

아 근데 여기서 단점 하나를 발견할 수 있다.
10번 유저 공격이라는 요청이 오면
함수 포인터는 공격이라는 행동은 담아줄 수 있지만 10번 유저 라는 것을 저장할 수가 없다.

코드로 나타내어 보면

// 클라 -> 나 20, 30 덧셈 해줘

using Func = int(*)(int, int);
Func func = &Add;

int x = 20;
int y = 30;
// x와 y의 정보를 받을 수 없음..
func(x, y);

그래서 함수 포인터반쪽 짜리라고 볼 수 있다.

그래서 우리는 함수 객체라는 것을 이용한다!
다음에는 함수 객체를 배워볼 것이다.

마무리

나중에 이 글을 보면서 함수 포인터에 개념을 머리에 박아넣기 위해 많은 예시들을 다 적어보았다.

꼭 이글을 여러 번 보고 기억이라도 하자!

profile
고3, 프론트엔드

0개의 댓글