개발하는 리프터 꽃게맨입니다.

[C/C++] 함수 포인터, 함수 객체, std::function 본문

언어/C, C++

[C/C++] 함수 포인터, 함수 객체, std::function

파워꽃게맨 2024. 1. 16. 10:48

함수 포인터

혹시 함수를 cout이나 printf에 넣어본 적 있나요? 

매개변수를 집어넣지 않은 순수한 함수 그 자체를 출력하면 매우 이상한 값이 출력됩니다.

 

 

이 숫자가 뜻하는 바가 무엇인지 아시겠나요?/

이는 함수가 존재하는 '주소'를 뜻하는 겁니다.

 

함수 역시 다른 데이터들과 마찬가지로 메모리에 저장되죠.

 

이 메모리 스택 중에 함수는 '코드 영역'에 저장되어 메모리상에 존재합니다.

출력된 내용은 코드 영역에 함수가 저장된 주소라고 할 수 있는 것이죠.

 

그러면, 함수의 주소를 가지고 여러 가지 장난을 칠 수도 있을까요?

물론 가능합니다.

이것을 가능하게 해주는 것이 함수 포인터입니다.

 

함수 포인터를 어떻게 선언하는지, 함수를 가리키기 위해서는 어떻게 해야 하는지 알아봅시다.

 

 

저도 여전히 조금 헷갈리는 것이 함수 포인트 문법인데,

간단히 말해서, 값으로 넣고자 하는 함수의 형태와 

함수 포인트의 데이터 형은 동일하게 생겨야 합니다.

 

반환형 (*이름) (매개변수 목록) = 함수

 

형태죠.

몇 가지 더 보도록 할게요.

 

 

함수 포인터 변수의 꼴이 어떤 식으로 선언되어야 하는지,

또 어떤 식으로 사용하는 지 기본적인 모양이 보이시나요?

 

문법이 살짝 힘들긴 하지만,

이런 식으로 함수에 별칭을 붙이는 느낌으로 사용할 수 있는 것이 함수  포인터입니다.

 

그러면 '함수 포인터'를 어디에 쓸 수 있을까요?

바로 함수를 매개변수에 전달하는 경우에 많이 사용합니다.

이런 것을 '콜백 Callback' 이라고 부르는 용어가 따로 있을 정도로

매우 중요한 개념이죠!

 

예시로 계산기 프로그램을 보겠습니다.

 

이런 식으로 함수 포인터를 이용하면 원하는 함수를 매개변수로ㄴ 전달하여 계산하는 계산기 프로그램을 만들 수 있습니다.

만약 새로운 연산을 추가하고 싶다면, 만들어서 넘겨주면 되겠죠?

 

다음은 정렬함수를 보겠습니다.

일반화 함수

 

함수 포인터로 넘겨줄 함수의 본체, void*를 int 형식으로 캐스팅
main 함수

 

일반화 선택 정렬함수에 매개변수로 비교함수를 넘겨주는 모습입니다.

(c 언어에는 template 문법이 없기에 일반화 함수를 위와 같이 작성하곤 했습니다. ㅎㅎ..)

 

일반화 함수 정의시 어떤 자료형이 들어올지 모릅니다.

구조체 형 자료가 들어온다고 했을 때, 컴파일러는 어떻게 해서 대소를 비교할지 알지 못합니다.

그래서 이를 해결하기 위해, 비교 함수 포인터를 매개변수에 추가하여, sort 함수를 사용할 때 직접 알맞은 함수를 만들어서 넘겨주는 방식으로 사용할 수 있습니다.

 

이런 용도 말고도

'특정 버튼'을 눌렀을 때, 적절한 함수가 수행되어야만 한다면, 

'PressButton' 함수의 매개변수 목록에 함수 포인터를 추가하여, 실행 시키고 싶은 함수의 주소를 넘겨주는 등

상당히 다양한 부분에서 사용할 수 있는 것이 함수 포인터입니다.

 

더보기

<함수 포인터를 매우 쉽게 만드는 법!>

 

 

함수 객체

그러나 함수 포인터 자체가 조금 난해할 수 있는 문법이라는 것은 논외로 두고

단점이 몇 개 있습니다.

 

1) 함수 시그니처 (반환형, 매개변수)가 맞지 않으면 사용할 수 없다.

2) 데이터 바인딩을 할 수 없다.

 

함수 객체는 이 문제를 모두 해결할 수 있습니다.

함수 객체란 일반적인 class (혹은 struct) 인데, 오로지 '콜백 용도'로만 사용하는 클래스를 뜻합니다.

 

 

이런 식으로 operator() 를 오버로딩해서

Add 클래스를 마치 Add에 대한 함수 포인터로 쓰는 것처럼 사용할 수 있습니다.

사실 '함수 객체'라는 개념보다는 조금 클래스의 특성을 이용한 꼼수처럼 보이지 않나요..?

그렇게 이해하시면 함수 포인터보다 이해하기는 더욱 쉽습니다..!

 

그럼 함수 객체의 장점은 뭘까요?

 

먼저, 클래스이니만큼 데이터를 바인딩 할 수 있습니다.

말 그대로, 멤버 변수를 가질 수 있다는 장점이 생긴다는 것이죠.

 

그리고 template 문법과 함께 사용한다면, 다양한 매개변수 시그니쳐들을 지원하기에 첫 번째 문제점도 해결할 수 있습니다.

( 필요하다면 오버로딩을 해도 되겠죠..? )

 

또, 함수 객체를 매개변수로 넘겨주고자 할 때, 템플릿 문법을 사용하면

함수 시그니쳐를 신경쓰지 않고 자유롭게 넘겨줄 수 있습니다.

 

 

함수 객체는 클래스의 특성을 이용하는 것이기 때문에

클래스의 문법을 그대로 따라갑니다.

 

더보기

<코드>

#include <iostream>
#include <cassert>
#include <vector>
using namespace std;

class Add
{
public:

	template <typename T>
	int operator()(T a, T b) const
	{
		return a + b;
	}

private:
	int _value = 0; //데이터도 바인딩 할 수 있어요!
};

class Sub
{
public:

	template <typename T>
	int operator()(T a, T b) const
	{
		return a - b;
	}

private:
	int _value = 0; //데이터도 바인딩 할 수 있어요!
};

template <typename Functor>
void CalcPrint(int a, int b, Functor f)
{
	cout << f(a, b) << endl;
}

int main()
{
	CalcPrint(10, 20, Add());
	CalcPrint(10, 20, Sub());
}

 

어떤가요? 엄청 쉽고 강력하지 않나요?

c++에서는 함수 포인터 형태는 잘 사용하지 않고, 템플릿을 통해 함수 객체를 넘겨주는 방식을 많이 사용합니다.

STL도 그렇게 작동하고 있고요! (대표적으로 priority_queue의 대소비교, unordered_map의 해시함수)

 

함수 객체가 사용되는 대표적인 예시 몇 가지만 보겠습니다.

 

1. 상속 구조를 이용한 함수 객체

 

더보기
#include <iostream>
#include <cassert>
#include <queue>
using namespace std;

class Work
{
public:
	Work() = delete;
	Work(int id) : id(id) {}
	virtual ~Work() = default;


	virtual void operator()() {};

protected:
	int id;
};
class Cook : public Work
{
public:
	Cook() = delete;
	Cook(int id) : Work(id) {}
	virtual ~Cook() = default;

	void operator()() override
	{
		cout << id << ": Cook!" << endl;
	}

};
class Wash : public Work
{
public:
	Wash() = delete;
	Wash(int id) : Work(id) {}
	virtual ~Wash() = default;

	void operator()() override
	{
		cout << id << ": Wash!" << endl;
	}

};
class Cleaning : public Work
{
public:
	Cleaning() = delete;
	Cleaning(int id) : Work(id) {}
	virtual ~Cleaning() = default;

	void operator()() override
	{
		cout << id << ": Cleaning!" << endl;
	}

};

void WorkThread(queue<unique_ptr<Work>>& q)
{
	while (!q.empty())
	{
		unique_ptr<Work> w = ::move(q.front());
		q.pop();

		assert(w != nullptr);

		(*w)();
	}

	cout << "All Work Complete!" << endl;
}

int main()
{
	srand(time(NULL));
	queue<unique_ptr<Work>> q;

	{
		q.push(make_unique<Cleaning>(rand()));
		q.push(make_unique<Cleaning>(rand()));
		q.push(make_unique<Wash>(rand()));
		q.push(make_unique<Cook>(rand()));
		q.push(make_unique<Cleaning>(rand()));
		q.push(make_unique<Wash>(rand()));
		q.push(make_unique<Cook>(rand()));
		q.push(make_unique<Cleaning>(rand()));
		q.push(make_unique<Wash>(rand()));
	}

	WorkThread(q);

}

코드가 길어서 전체 코드는 따로 첨부하였습니다.

보시면 함수 객체 자체는 Work 라는 최상위 클래스로 묶어서 관리하고 있습니다.

이러면 Cook, Wash, Cleaning 이라는 일감을 Work로 묶어서

일감만 처리하는 쓰레드에 던져주면, 알아서 자기가 Work를 처리하게 됩니다.

 

위 코드에서 메인 스레드는 일감을 만들어서, 큐에 넣어주기만 하고 있죠

'일'이라는 함수 객체를 큐에 넣어놓기만 하면, 서브 스레드가 큐를 확인하면서

함수를 계속해서 처리합니다.

(스레드는 구현 안 했습니다만, 동일하게 동작합니다.)

 

아무래도 '객체'와 '클래스'의 특징을 잘 활용한 예시라고 할 수 있겠죠.

 

2. 템플릿 문법을 이용한 선택 정렬

 

어떤가요? 위 '함수 포인터 + 일반화 함수' 보다 '함수 객체 + 템플릿 함수'의 조합이 좀 더 구현면에서

쉽고 직관적이지 않나요?

 

저는 함수 객체가 직관적이고 함수 포인터에 비해서 이점이 더 많기 때문에

대부분의 경우에선 함수 객체를 이용합니다.

 

(c 스타일 라이브러리를 사용하려면 그래도 함수 포인터를 사용하는 방법 정도는 알아두시면 좋습니다.)

std::function

기존 함수 포인터의 개념을 확장한 것으로, 어떤 callable 한 대상이던 다 받을 수 있는 개념을 뜻합니다.

 

callable이라 함은 '호출할 수 있는 모든 것'으로

함수, 람다식, 함수 객체, 멤버 함수 등이 있습니다.

 

std::function의 가장 큰 장점은

시그니쳐만 맞다면 동일한 callable를 다 다룰 수 있다는 것입니다.

 

예시를 보여드리겠습니다.

 

C 스타일 함수 포인터의 발전형이라고 여길 수 있는 것이

C++ 은 callable 한 형태가 C보다 더 많아졌습니다.

 

그래서 callable을 한 번에 다루고 싶을 때, 제약이 생겼죠

함수 포인터는 함수의 주소밖에 못 다루고,

함수 객체는 람다와 같이 묶어서 쓸 수 없습니다.

 

그러나 std::functional을 통해서 callable 한 함수들을 한 번에 관리할 수 있는 것은

매우 강력한 기능입니다.

 

그 외에도 매개변수의 값을 바인딩하는 std::bind와 같은 함수도 있긴 한데,

엄청 많이 쓰이는 기능은 아닌 것 같아서

필요하다면 나중에 또 다른 포스팅으로 찾아뵙겠습니다.

람다식?

익명 함수라고 하는 것인데,

C++ 11부터 지원하는 즉석에서 함수를 만드는 기능이라고 이해하시면 됩니다.

정확하게 말하면, 함수 객체를 즉석으로 만드는 이름 없는 함수를 만들 수 있는 것이

람다입니다.

 

자세한 것은 다른 포스팅에서 추가적으로 포스팅하겠습니다.