강의 보고 공부한 것을 정리하는 목적으로 작성된 글이므로 틀린 점이 있을 수 있음에 양해 부탁드립니다. (피드백 환영입니다)
저번에는 함수 포인터만
으로는 한계가 있다는 것을 알게 되었다.
그래서 함수 객체
라는 것을 사용한다고 하였는데
함수 객체
의 사용이유를 알아보자
함수 포인터
의 단점
함수 객체
는 클래스를 만들 때 객체를 하나 만들어주는데
그 객체의 용도가 일반적인 용도(Player, Dog, Enemy 등..)가 아니라 함수를 호출하는 용도로 사용하는 객체를 함수 객체
라고 한다.
함수와 더불어 상태를 묶어 관리할 수 있는 개념이 Functor
라는 개념이다.
class Functor
{
public:
public:
int _value = 0;
}
이런식으로 Functor라는 클래스를 만들고
이 Functor로 객체를 만들어서 값을 넣어주고 함수를 만들어주면 그게 동작이고 필드가 상태이기 때문에 애당초에 함수 포인터까지 갈 필요도 없이 그냥 클래스로 객체를 만들어서 그 객체를 함수를 호출하는 용도로도 사용하고 상태도 데이터로 가지고 있으면 된다.
하지만 일반함수랑 똑같이 호출하는 것(괄호()) 처럼 만들어주기 위해서 연산자 오버로딩을 해준다.
class Functor
{
public:
void operator()()
{
cout << "Functor Test" << endl;
cout << _value << endl;
}
public:
int _value = 0;
}
int main()
{
Functor func;
func._value = 10;
func();
}
이런식으로 사용할 수 있다.
그래서 상태를 가질 수 있게 되었고
시그니처가 안 맞으면 사용이 불가능한 부분은
class Functor
{
public:
void operator()()
{
cout << "Functor Test" << endl;
cout << _value << endl;
}
void operator()(int a)
{
cout << "Functor Test" << endl;
_value += n;
cout << _value << endl;
}
public:
int _value = 0;
}
int main()
{
Functor func;
func._value = 10;
func();
func(10);
}
이런식으로 오버로딩을 해주어서 다양하게 만들어줄 수도 있다.
그럼 여기서 살짝 애매한 부분이 있을 수 있다.
우리가 전에 만들었던 DoSomething이라는 함수처럼 함수 포인터를 받아서 다양하게 동작할 수 있게 하였다.
using Func = int(*)(int a, int b);
void DoSomething(int a, int b, Func func)
{
return func(a, b);
}
이런식으로 말이다.
하지만 이제 우리는 명확한 특정함수의 포인터를 지정해서 넣어주지 않고
이거 자체를 객체로 넣어주려면 어떤식으로 해야하는지가 고민일 수 있다.
즉 이런상황일 때
struct AddStruct
{
public:
int operator()(int a, int b)
{
return a + b;
}
};
using Func = int(*)(int a, int b);
void DoSomething(int a, int b, Func func)
{
return func(a, b);
}
int main()
{
AddStruct func;
DoSomething(10, 20, func);
}
이럴때에는 템플릿 문법을 이용해주면 된다.
struct AddStruct
{
public:
int operator()(int a, int b)
{
return a + b;
}
};
template<typename T>
void DoSomething(int a, int b, T func)
{
return func(a, b);
}
int main()
{
AddStruct func;
DoSomething(10, 20, func);
}
이런식으로 해줄 수 있다.
여기서 잠깐 다른 이야기를 해보자면
C#의 generic
과 ++의 template
는 확연히 다른데
C++의 template는 C#의 다이나믹 문법과 비슷하다.
정확한 규약은 정해지지 않았지만 일단 해보고 아다리가 맞다면 통과해주는 느낌이다.
T가 어떤 형식이 되어야 하는지의 힌트도 없다.
우리가 연산자 오버로딩으로 ()를 오버로딩을 하였기에 func(a, b)가 통과가 되어서 실행이 된다.
그래서 Functor를 이용할 때는 template와 궁합이 잘 맞는다.
우선순위 큐에 greater를 우리가 만들어서 넣어서 사용할 수도 있다.
연산자 오버로딩만 똑바로 해준다면 정상적으로 작동이 되기 때문이다.
(우리가 위에서 DoSomething을 만들었던 원리와 비슷하다)
그래서 사실 Functor를 사용하게 된다면 함수 포인터를 사용할 일은 거의 없게 된다.
Functor가 상위호환이기 때문이다.
그래도 뭐 자기가 사용하기 나름이기 때문에 적절하게 사용해주면 되겠다.
그럼 다시 전에 들었던 예시를 한 번 들어보겠다.
클라가 10, 20 좌표로 이동한다고 요청을 보낸다.
그러면 우리는 이런식으로 만들 수 있다.
class MoveJob
{
public:
MoveJob(int x, int y) : x(x), y(y) { }
void operator()()
{
cout << "Player Move" << endl;
}
public:
int x;
int y;
};
int main()
{
MoveJob* job = new MoveJob(10, 20);
// 다른 처리..
// ...
(*job)();
}
이렇게 MoveJob이라는 클래스를 만들어주고
우리는 MoveJop이라는 객체를 만들어줘서 서버에서 들고있다가
나중에 때가 된다면 호출하는 식으로 만들어 줄 수 있다.
그럼 결국에는 Movejob이 데이터도 저장할 수 있을 뿐만아니라 행동까지도 저장을 할 수 있다.
공격이 필요하다면 공격도 클래스를 만들면 된다.
물론 요청하는 행위마다 클래스를 만드는 것이 노가다가 심하다고 생각하면 람다로 처리를 해도 된다. (프로젝트마다 다르다고 한다)
그래서 이런 느낌으로 활용이 가능하다.
그러면 job은 어떤 자료구조로 관리를 하는게 좋을까?
보통은 Queue
를 사용하는 것이 일반적일 것이다. (클라가 요청한 순서대로 실행하기 때문)
이런식으로 관리하게 되면 일감이 몰려도 순차적으로 처리가 되는 장점 뿐만아니라 멀티쓰레드 코드에서도 이점이 많아진다.
근데 강사분이 엔씨를 다녔을 때에는 연결리스트
를 사용하였다고 한다.
왜 그럴까?
연결리스트
의 딱 한가지 이점은 중간에 뺄 위치를 알고 있다면 빠르게 뺄 수가 있다.
만약 보스가 1분 뒤에 엄청난 광역기를 사용한다고 요청 되었는데
보스가 죽으면 그 요청을 빼야하기 때문이다.
큐로 해도 안되지는 않는다.
그럼 move, attack 등 여러가지의 job이 있다면 어떤식으로 queue에 넣어줄 것인가?
그럴 때에는 최상위 job클래스를 만들어서 상속시키고 queue에 들고 있게해주면 다 관리할 수 있을 것이다.
그럼 여기서 깜짝퀴즈로 최상위 클래스에는 무엇을 해줘야 할까?
라고 하면 바로 virtual 소멸자를 해줘야 한다.
저번에도 말했듯이 virtual 소멸자를 해주지 않는다면
메모리 누수와 다양한 크래시가 일어날 수 있다.
Functor는 굉장히 중요하다고 볼 수 있다.
왜냐하면 뭐 알고리즘, stl등 다 중요하지만
일반적으로 일감을 묶어서 넣어줘서 관리한다는 것이 엄청난 장점이라고 볼 수 있다.
이 부분은 문법이 헷갈릴 수는 있지만 내용자체는 우리가 배웠던것에서 별거 추가가 안되었다고 볼 수 있다.
많이 복습해보자!