강의 보고 공부한 것을 정리하는 목적으로 작성된 글이므로 틀린 점이 있을 수 있음에 양해 부탁드립니다. (피드백 환영입니다)
우리가 저번에 사용했었던 우선순위 큐를 정의할 때
대소비교를 바꾸는데 사용하는 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이라고 부르겠다!라고 읽어주면 된다.
그럼 단계별로 나눠서 알아보자
void Print();
void(*)();
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);
그래서 함수 포인터
는 반쪽 짜리라고 볼 수 있다.
그래서 우리는 함수 객체
라는 것을 이용한다!
다음에는 함수 객체
를 배워볼 것이다.
나중에 이 글을 보면서 함수 포인터에 개념을 머리에 박아넣기 위해 많은 예시들을 다 적어보았다.
꼭 이글을 여러 번 보고 기억이라도 하자!