References
- Professional C++
- https://en.cppreference.com/w/
Contents
- Function Object (Functor)
어떤 클래스의 함수 호출 연산자(function call operator)를 오버로딩해서 그 클래스의 객체를 함수 포인터처럼 사용하게 만들 수 있습니다. 이렇게 사용하는 객체를 함수 객체(function objects) 또는 펑터(functor)라고 부릅니다. 단순한 함수 대신 함수 객체를 사용하면 여러 호출들 간에 상태를 유지할 수 있다는 장점이 있습니다.
어떤 클래스 함수 객체를 만들기 위해서, 단지 함수 호출 연산자를 오버로딩하면 됩니다.
아래의 간단한 함수 객체 예제가 있습니다.
class IsLargerThan
{
public:
explicit IsLargerThan(int value) : m_value{ value } {}
bool operator()(int value1, int value2) const {
return value1 > m_value && value2 > m_value;
}
private:
int m_value;
};
IsLargerThan 클래스의 오버로딩된 함수 호출 연산자는 const가 지정되어 있습니다. 이 예제에서 엄격하게 요구되는 것은 아니지만, 나중에 표준 라이브러리 알고리즘을 사용한다면 함수 호출 연산자는 반드시 const이어야 합니다.
find_if()나 accumlate()와 같은 많은 표준 라이브러리 알고리즘은 함수 포인터, 펑터를 비롯한 콜백을 인수로 받아서 알고리즘의 동작을 변경할 수 있습니다. C++은 이를 위해 여러 가지 펑터 클래스를 <functional> 헤더 파일에 정의해두었습니다. 이러한 것들은 주로 콜백 연산에 주로 사용됩니다.
<functional> 헤더 파일에는 bind1st(), bind2nd(), mem_fun(), mem_fun(), mem_fun_ref(), ptr_fun()과 같은 함수도 정의되어 있습니다. 이 함수들은 C++17부터 공식적으로 삭제되었기 때문에 이번 포스팅에서 다루지는 않겠습니다. 프로그래밍할 때 이 함수들을 사용하는 것이 좋습니다.
1. Arithmetic Function Objects
C++은 다섯가지 이항 산술 연산자(plus, minus, multiplies, divides, modulus)에 대한 펑터 클래스 템플릿을 제공합니다. 여기에 추가로 단항 negate도 제공합니다. 이러한 클래스들은 피연산자의 타입으로 템플릿화해서 클래스를 만들면 실제 연산자에 대한 래퍼로 사용할 수 있습니다. 템플릿 타입의 매개변수를 한 개 또는 두 개 받아서 연산을 수행한 뒤 결과를 리턴합니다.
plus 클래스 템플릿은 다음과 같이 사용할 수 있습니다.
std::plus<int> myPlus;
int res{ myPlus(4, 5) };
std::cout << res << std::endl;
이렇게 사용해도 크게 더 좋아지는 것은 없습니다. plus 클래스 템플릿을 사용할 필요 없이 곧바로 operator+를 사용해도 됩니다. 하지만 이렇게 산술 함수 객체를 사용하면 다른 함수의 콜백으로 전달하기 좋습니다.
예를 들어, 다음과 같이 정의된 함수 템플릿 accumulateData()를 살펴보겠습니다. 이 함수 템플릿은 Operation을 파라미터로 받습니다. 그리고 accumulateData()를 호출하는 geometricMean() 함수의 구현은 미리 정의되어 있는 multiplies 함수 객체의 인스턴스를 사용합니다.
template<typename Iter, typename StartValue, typename Operation>
auto accumulateData(Iter begin, Iter end, StartValue startValue, Operation op)
{
auto accumulated{ startValue };
for (Iter iter = begin; iter != end; ++iter) {
accumulated = op(accumulated, *iter);
}
return accumulated;
}
double geometricMean(std::span<const int> values)
{
auto mult{ accumulateData(cbegin(values), cend(values), 1, std::multiplies<int>{}) };
return pow(mult, 1.0 / values.size()); // pows() requires <cmath>
}
여기서 geometricMean의 파라미터 타입으로 사용한 span은 <span>에 정의되어 있으며 C++20부터 추가된 것입니다. span을 사용할 수 없다면 vector를 사용해도 됩니다.
그리고 multiplies<int>()는 multiplies 함수 클래스 템플릿을 int 타입으로 인스턴스화합니다.
다른 산술 함수 객체도 이와 같이 동작합니다.
산술 함수 객체는 산술 연산자에 대한 래퍼에 불과합니다. 알고리즘에서 함수 객체를 콜백으로 사용하려면 반드시 컨테이너에 담긴 객체가 해당 연산(ex, operator+, operator*)를 구현해야 합니다.
2. Transparent Operator Functors
C++은 Transparent Operator Functor(투명 연산자 펑터)도 제공합니다. 이 펑터를 이용하면 템플릿 타입 인수를 생략해도 됩니다. 예를 들어, multiplies<int>{} 대신 multiplies<>{}라고만 적어도 됩니다.
double geometricMean(std::span<const int> values)
{
auto mult{ accumulateData(cbegin(values), cend(values), 1, std::multiplies<>{}) };
return pow(mult, 1.0 / values.size()); // pows() requires <cmath>
}
transparent 연산자에서 굉장히 중요한 특징은 이종 타입을 지원한다는 점입니다. 즉, non-transparent 펑터보다 간결할 뿐만 아니라 실질적인 기능도 더 뛰어납니다. 예를 들어, 다음 코드는 vector가 정수를 담고 있지만 transparent 연산자 펑터를 사용해서 1.1이라는 double 값을 startValue로 사용하도록 지정했습니다. 그러면 accumulateData()는 그 결과를 double로 계산해서 6.6이란 값을 리턴합니다.
만약 이 코드를 다음과 같이 non-transparent 연산자 펑터, multiplies<int>{}로 작성하면 accumulateData()는 결과를 정수로 계산해서 6을 리턴합니다. 이 코드를 컴파일하면 데이터 손실이 발생할 수 있다는 경고 메세지가 출력됩니다.
std::vector<int> nums{ 1, 2, 3 };
double result = accumulateData(cbegin(nums), cend(nums), 1.1, std::multiplies<int>{});
마지막으로 transparent 연산자를 사용하면 성능이 향상될 수 있는데, 바로 뒤에서 설명하도록 하겠습니다.
항상 transparent operator functor를 사용하는 것을 권장합니다.
3. Comparison Function Objects
C++은 산술 함수 객체 클래스뿐만 아니라 표준 비교 연산(standard comparison operation)도 제공합니다 : equal_to, not_equal_to, less, greater, less_equal, greater_equal.
priority_queue와 연관 컨테이너의 원소를 비교할 때 디폴트 연산자로 less가 사용되는데, priority_queue의 비교 기준을 변경하는 방법을 알아보겠습니다. 먼저 std::less를 디폴트 비교 연산자로 사용하는 priority_queue를 이용하는 방법은 다음과 같습니다.
#include <queue>
int main()
{
std::priority_queue<int> myQueue;
myQueue.push(3);
myQueue.push(4);
myQueue.push(2);
myQueue.push(1);
while (!myQueue.empty()) {
std::cout << myQueue.top() << " ";
myQueue.pop();
}
}
위 코드를 실행하면, "4 3 2 1"을 출력합니다.
여기서 볼 수 있듯이 less로 비교하기 때문에 큐에 담긴 원소가 내림차순으로 삭제됩니다. 이 비교 기준을 템플릿 인수에 greater를 지정해서 비교 연산자를 변경할 수 있습니다. priority_queue 템플릿은 다음과 같이 정의되어 있습니다.
template<class T, class Container = vector<T>, class Compare = less<T>>;
그런데 Compare 타입 매개변수가 마지막에 있습니다. 그래서 이 값을 지정하려면 컨테이너 타입 매개변수도 지정해주어야 합니다. 앞에서 본 priority_queue가 greater를 기준으로 원소를 오름차순으로 정렬하게 만드려면 priority_queue를 다음과 같이 선언해주면 됩니다.
std::priority_queue<int, std::vector<int>, std::greater<>> myQueue;
그럼 위의 main문을 다시 실행하면, "1 2 3 4"를 출력합니다.
여기서 myQueue를 transparent 연산자인 greater<>로 정의했습니다. 비교 함수 타입을 인수로 받는 표준 라이브러리 컨테이너를 사용할 때는 항상 transparent 연산자를 사용하는 것이 좋은데, 대체로 non-transparent 연산자보다 성능이 좋습니다. 예를 들어 set<string>에서 문자열 리터럴로 주어진 키로 쿼리 연산을 수행할 때 non-transparent 연산자를 사용하면 불필요한 복사 연산이 발생할 수 있습니다. 이는 문자열 리터럴로부터 string 인스턴스를 생성하기 때문입니다.
(sv 리터럴을 사용하려면 using namespace std::string_view_literals; 를 추가해주어야 합니다.)
std::set<std::string> mySet;
auto i1{ mySet.find("Key") }; // string constructed, allocates memory!
auto i2{ mySet.find("Key"sv) }; // compile error !
하지만 transparent 연산자로된 비교자(comparator)를 사용하면 이러한 복사 연산을 피할 수 있습니다. 이를 heterogeneous lookups라고 부릅니다.
std::set<std::string, std::less<>> mySet;
auto i1{ mySet.find("Key") }; // No string constructed, no memory allocated.
auto i2{ mySet.find("Key"sv) }; // No string constructed, no memory allocated.
4. Logical Function Objects
C++은 logical_not(operator!), logical_and(operator&&), logical_or(operator||)라는 3가지 논리 비교 연산자에 대한 함수 객체 클래스를 제공합니다. 이러한 논리 연산자는 true나 false 값만 다룹니다.
예를 들어, Logical Functor를 사용하여 컨테이너에 존재하는 모든 부울 플래그가 true인지 체크하는 allTrue() 함수를 구현할 수 있습니다.
bool allTrue(const std::vector<bool>& flags)
{
return accumulateData(begin(flags), end(flags), true, std::logical_and<>{});
}
마찬가지로, 컨테이너의 부울 플래그가 최소한 하나라도 true면 true를 리턴하는 anyTrue() 함수를 logical_or functor로 구현할 수 있습니다.
bool anyTure(const std::vector<bool>& flags)
{
return accumulateData(begin(flags), end(flags), false, std::logical_or<>{});
}
여기서 소개한 allTrue()와 anyTrue() 함수는 예를 보여주기 위해 만든 것입니다. 표준 라이브러리에는 std::all_of()와 std::any_of()란 알고리즘으로 이러한 연산을 지원합니다. 게다가 short-circuiting도 지원하기 때문에 성능이 훨씬 뛰어납니다.
5. Bitwise Function Objects
C++은 bit_and(operator&), bit_or(operator|), bit_xor(operator^), bit_not(operator~)와 같은 모든 비트 연산자에 대한 함수 객체도 제공합니다. 비트 연산자에 대한 펑터는 컨테이너에 담긴 모든 원소에 대해 비트 연산을 수행하는 transform() 알고리즘에서 사용할 수 있습니다.
6. Adapter Function Objects
표준에서 제공하는 함수 객체가 요구사항에 맞지 않을 수 있습니다. 이러한 단점을 보완하기 위해 adaptor function objects(어댑터 함수 객체)를 제공합니다. 어댑터 함수 객체는 함수 객체, 람다 표현식, 함수 포인터를 비롯한 모든 호출 가능한 것에 적용할 수 있습니다. 이러한 어댑터는 미약하게나마 함수 합성(functional composition)을 지원합니다. 다시 말해 여러 함수를 하나로 합쳐서 원하는 기능을 구현할 수 있습니다.
6.1 Binders
바인더를 이용하면 호출 가능 개체의 매개변수를 일정한 값으로 묶어둘(바인딩) 수 있습니다. 이를 위해 <functional> 헤더 파일에 정의된 std::bind()를 이용하면 호출 가능 개체의 매개변수를 원하는 방식으로 바인딩할 수 있습니다. 이때 매개변수를 고정된 값에 바인딩할 수도 있고, 매개변수의 순서를 바꿀 수도 있습니다. 머릿 속에 잘 그려지지 않을 수 있는데, 예시를 보면 확실하게 이해될 것 입니다.
다음과 같이 인수 두 개를 받는 func() 함수를 살펴보겠습니다.
void func(int num, std::string_view str)
{
std::cout << "func(" << num << ", " << str << ")\n";
}
그리고 바로 아래에 나오는 코드는 func()의 두 번째 인수를 myString이라는 고정된 값으로 바인딩하도록 bind()를 사용하는 방법을 보여줍니다. 그 결과는 f1()에 저장됩니다. 여기서 auto 키워드를 사용했는데, C++ 표준에 bind()의 리턴 타입이 명확히 정의되어 있지 않기 때문입니다. 따라서 구현마다 다를 수 있습니다.
특정한 값에 바인딩 되지 않은 인수는 반드시 std::placeholders 네임스페이스에 정의된 _1, _2, _3 등으로 지정해야 합니다. f1()의 정의에서 _1은 func()를 호출할 때 f1()의 첫 번째 인수가 들어갈 지점을 지정합니다. 그러면 다음과 같이 f1()에 정수 타입 인수 하나만 지정해서 호출할 수 있습니다.
std::string myString{ "abc" };
auto f1{ std::bind(func, std::placeholders::_1, myString) };
f1(16);
이를 실행하면 "func(16, abc)"를 출력합니다.
bind()로 인수의 순서를 바꿀 수도 있습니다. 예를 들어, 다음 코드에서 _2는 func()를 호출할 때 f2()의 두 번째 인수가 들어갈 지점을 지정합니다. 즉, f2()의 첫 번째 인수는 func()의 두 번째 인수가 되고, f()의 두 번째 인수는 func()의 첫 번째 인수가 됩니다.
auto f2{ std::bind(func, std::placeholders::_2, std::placeholders::_1) };
f2("Test", 32);
위 코드를 실행하면 "func(32, Test)"가 출력됩니다.
<functional> 헤더 파일에 std::ref()와 cref() 헬퍼 템플릿 함수를 사용하면 레퍼런스나 const 레퍼런스를 바인딩할 수 있습니다. 예를 들어, 다음과 같은 함수를 살펴보도록 하겠습니다.
void increment(int& value) { ++value; }
이 함수를 다음과 같이 호출하면 index 값이 1이 됩니다.
int index{ 0 };
increment(index);
이 함수를 다음과 같이 bind()로 호출하면 index 값이 증가하지 않습니다. index의 복제본에 대한 레퍼런스가 increment() 함수의 첫 번째 매개변수로 바인딩되기 때문입니다.
auto incr{ std::bind(increment, index) };
incr();
다음과 같이 std::ref()로 페러런스를 제대로 지정하면 index값이 증가합니다.
auto incr{ std::bind(increment, std::ref(index)) };
incr();
바인딩 매개변수를 오버로딩한 함수와 함께 사용할 때 사소한 문제가 발생할 수 있습니다. 예를 들어, 다음과 같이 두 가지 overloaded() 함수가 있다고 가정해봅시다. 하나는 정수를 받고 하나는 부동소수점을 받습니다.
void overloaded(int num) {}
void overloaded(float f) {}
이렇게 오버로딩된 함수에 대해 bind()를 사용하려면 둘 중 어느 함수에 바인딩할지 명시적으로 지정해주어야 합니다. 예를 들어 다음과 같이 작성하면 에러가 발생합니다.
auto f3{ std::bind(overloaded, std::placeholders::_1) }; // ERROR
만약 부동소수점 인수를 받는 오버로딩 함수의 매개변수를 바인딩하고 싶다면 다음과 같이 작성합니다.
auto f4{ std::bind((void(*)(float))overloaded, std::placeholders::_1) }; // OK
bind()의 또 다른 예로 아래의 findMatches()에서 MatchHandler를 클래스의 메소드로 사용하는 것이 있습니다.
template<typename Matcher, typename MatchHandler>
void findMatches(std::vector<int>& values1, std::vector<int>& values2,
Matcher matcher, MatchHandler handler)
{
if (values1.size() != values2.size()) { return; } // must be same size
for (size_t i = 0; i < values1.size(); ++i) {
if (matcher(values1[i], values2[i])) {
handler(i, values1[i], values2[i]);
}
}
}
bool intEqual(int value1, int value2) { return value1 == value2; }
그리고 Handler 클래스를 다음과 같이 작성할 수 있습니다.
class Handler
{
public:
void handleMatch(size_t position, int value1, int value2)
{
std::cout << "Match found at position " << position << " ("
<< value1 << ", " << value2 << ")\n";
}
};
여기서 어떻게 handleMatch() 메소드를 findMatches()의 마지막 파라미터로 전달할 수 있을까요? 문제는 메소드는 반드시 객체의 문맥에서 호출되어야 한다는 것입니다. 기술적으로 클래스의 모든 메소드는 암시적으로 첫 번째 파라미터를 갖는데, 이는 객체 인스턴스의 포인터이며 메소드의 바디에서 this라는 이름으로 접근할 수 있습니다. 하지만 MatchHandler는 size_t와 int 2개, 오직 3개의 파라미터만을 받고 있으므로 메소드와 일치하지 않습니다.
이에 대한 해결 방법은 암시적인 첫 번째 파라미터를 다음과 같이 바인딩하는 것입니다.
int main()
{
Handler handler;
std::vector values1{ 11, 22, 33, 44 };
std::vector values2{ 44, 33, 33, 11 };
findMatches(values1, values2, intEqual, std::bind(&Handler::handleMatch, &handler,
std::placeholders::_1, std::placeholders::_2, std::placeholders::_3));
}
또한, 표준 함수 객체의 파라미터를 바인딩하는데 bind()를 사용할 수 있습니다. 예를 들어, greater_equal의 두 번째 파라미터를 항상 고정된 값을 비교하도록 바인딩할 수 있습니다.
auto greaterEqualTo100{ std::bind(std::greater_equal<>{}, std::placeholders::_1, 100) };
C++11 이전에는 bind2nd()와 bind1st()도 제공했지만, 둘다 C++11부터 폐기되었습니다. C++17부터는 표준에서 완전히 삭제되었으며, 이 함수 대신 람다 표현식이나 bind()를 사용하는 것을 권장합니다.
6.2 Negator
부정 연산자(negator)는 바인더와 비슷하지만 호출 가능 개체의 결과를 반전시킨다는 점이 다릅니다. 예를 들어 위에서 정의한 findMatches()를 사용하여 일치하지 않는 값들의 쌍을 찾기를 원한다면, intEqual()의 결과에 not_fn() negator adapter를 적용할 수 있습니다.
bool intEqual(int value1, int value2) { return value1 == value2; }
void printMatch(size_t position, int value1, int value2)
{
std::cout << "Mismatch found at position " << position << " ("
<< value1 << ", " << value2 << ")\n";
}
int main()
{
std::vector values1{ 11, 22, 33, 44 };
std::vector values2{ 44, 33, 33, 11 };
findMatches(values1, values2, std::not_fn(intEqual), printMatch);
}
not_fn()는 매개변수로 받은 호출 가능 개체의 호출 결과를 모두 반전시킵니다.
보다시피 펑터와 어댑터를 사용하면 코드가 조금 복잡해집니다. 그래서 가능하면 펑터보다는 람다 표현식을 사용하는 것을 권장합니다. 람다 표현식은 다음 포스팅에서 다루어 볼 예정입니다.
std::not1()과 std::not2()는 C++17부터 폐기되었으며, C++20부터 완전히 제거되었습니다. 가능하면 이들을 사용하지 않는 것이 좋습니다.
6.3 Calling Member Functions
알고리즘의 콜백으로 클래스 메소드에 대한 포인터를 전달하고 싶을 수 있습니다. 예를 들어, 특정한 조건을 만족할 때 컨테이너로부터 string을 출력하는 다음의 알고리즘이 있다고 가정해보겠습니다.
template<typename Matcher>
void printMatchingStrings(const std::vector<std::string>& strings, Matcher matcher)
{
for (const auto& string : strings) {
if (matcher(string)) { std::cout << string << " "; }
}
}
이 알고리즘에서 non-empty string들을 모두 출력하려면 string의 empty() 메소드를 사용하면 됩니다. 그러나 만약 string::empty() 포인터를 printMatchingStrings()의 두 번째 인수로 전달한다면, 알고리즘은 함수 포인터나 펑터 대신 메소드에 대한 포인터를 받았는지 알 방법이 없습니다. 메소드 포인터를 호출하는 코드는 일반 함수 포인터를 호출하는 코드와 다른데, 이는 메소드 포인터를 호출하는 코드는 반드시 객체의 문맥 내에서 호출되어야 하기 때문입니다.
C++은 이를 위해 mem_fn()이라는 변환 함수를 제공합니다. 알고리즘의 콜백으로 메소드 포인터를 전달할 때 이 함수로 변환한 결과를 알고리즘의 콜백으로 전달하면 됩니다. 예를 들어 다음 코드는 이를 잘 설명해주는데, mem_fn()의 결과를 반전시키기 위해 not_fn()와 함께 사용하고 있습니다. 주목해야할 것은 메소드 포인터를 &string::empty로 지정해준 것인데, 여기서 &string::은 필수입니다.
int main()
{
std::vector<std::string> values{ "Hello", "", "", "World", "!" };
printMatchingStrings(values, std::not_fn(std::mem_fn(&std::string::empty)));
}
not_fn(mem_fn())은 함수 객체를 생성하며 printMatchingString()의 콜백으로 사용됩니다. 매번 호출될 때마다 이 함수 객체는 empty() 메소드를 호출하고 그 결과를 반전시킵니다.
하지만 보다 직관적이고 읽기 쉽도록 구현하려면 다음 포스팅에서 설명할 람다 표현식을 사용하는 것이 좋습니다.
'프로그래밍 > C & C++' 카테고리의 다른 글
[C/C++] 가변 인자 리스트 (0) | 2022.02.23 |
---|---|
[C++] Lambda Expression (람다 표현식) (0) | 2022.02.23 |
[C++] Function Pointer (함수 포인터) (0) | 2022.02.22 |
[C++] Iterator (이터레이터, 반복자) (0) | 2022.02.21 |
[C++] 연산자 오버로딩 (2) (0) | 2022.02.20 |
댓글