[전문가를 위한 C++] 함수 포인터, 함수 객체, 람다 표현식
‘전문가를 위한 C++ - Marc Gregoire 지음, 남기혁 옮김’ 책을 참고하여 작성한 포스트입니다.
일급 함수
- c++의 함수는 일급 함수(first-class function)이다.
- 일급 함수란 일반 변수처럼 다른 함수의 인수로 전달되거나, 다른 함수에서 리턴하거나, 변수에 대입 가능한 함수이다.
- 호출될 수 있다는 뜻에서 콜백(callback)이라고도 부른다.
- 함수 포인터, 함수 객체, 람다 표현식 등으로 만들 수 있다.
- operator()를 오버로드한 것을 함수 객체(function object) 혹은 functor라고 부른다.
- 표준 라이브러리는 콜백 객체를 생성하는데 사용되는 클래스와 기존 콜백 객체에 연결할 수 있는 클래스를 제공한다.
함수 포인터(function pointer)
- 함수도 주소가 부여되어 있다.
- 주소를 이용해 변수처럼 사용이 가능하다.
- 함수 포인터는 매개변수 타입과 리턴 타입에 따라 결정된다.
- 타입 앨리어스를 이용해 쉽게 표현할 수도 있다.
- 다른 함수를 매개변수로 받거나 함수를 리턴하는 함수를 고차함수(higher-order function)라고 부른다.
- 함수 포인터를 사용하면 인수로 전달된 함수로 커스터마이즈가 가능해 함수 하나를 다양한 용도로 활용이 가능하다.
- 함수 포인터보다는 std::function 을 활용하는 것이 더 바람직하긴 하다.
- 함수 포인터는 주로 동적 라이브러리에 있는 함수에 대한 포인터를 구할 때 사용한다.
// 두 함수포인터를 타입 앨리어스를 이용해 표현
using Matcher = bool(*)(int, int);
using MatchHanler = void(*)(size_t, int, int);
// 두 콜백을 매개변수로 받는 함수
void FindMatches(vector<int> Values1, vector<int> Values2, Matcher CallbackMatcher, MatchHandler CallbackMatchHandler)
{
if (Values1.size() != Values()) return;
for (size_t i=0; i < Values1.size(); ++i)
{
if (CallbackMatcher(Values1[i], Values2[i]))
{
CallbackMatchHandler(i, Values1[i], Values2[i]);
}
}
}
// 콜백 함수 정의
bool IntEqual(int Item1, int Item2) {return Item1 == Item2;}
void PrintMatch(size_t Position, int Value1, int Value2)
{
cout << format("Match found at position {} ({} {})"),
Position, Value1, Value2 << endl;
}
// 인수로 전달
vector Values1 {1, 2, 3};
vecotr Values2 {5, 2, 4};
FindMatches(Values1, Values2, &IntEqual, &PrintMatch);
메서드 및 데이터 멤버를 가리키는 포인터
- 클래스의 메서드 및 데이터 멤버에 대한 주소도 얼마든지 가져올 수 있다.
- 비 static 데이터 멤버나 메서드는 반드시 객체를 통해 가져와야 한다. 둘은 원래 객체에 제공하기 위한 것이다.
- 해당 객체 문맥에서 포인터를 역참조 해야 한다.
// MethodPtr은 비 static const 메서드를 가리키는 포인터다.
// 이 메서드는 인수를 받지 않고 int를 반환한다.
// 선언과 동시에 변수의 값을 GetSalary 메서드로 초기화한다.
// Employee:: 가 붙는 점을 제외하면 포인터를 정의하는 것과 같다.
// &를 반드시 붙여야 한다.
int (Employee::*MethodPtr) () const { &Employee::GetSalary };
Employee EmployeeInstance { "Kim", "Lee"};
// operator()가 *보다 우선순위가 높으므로
// 소괄호 처리 해주어야 한다
cout << (EmployeeInstance.*MethodPtr)() << endl;
// 객체를 가리키는 포인터 이용
Employee* EmployeeInstancePtr = new Employee {"Kim", "Son"};
cout << (EmployeeInstancePtr->*MethodPtr)() << endl;
// 타입 앨리어스 활용
using PtrToGet = int (Employee::*) () const;
PtrToGet MethodPtr = { &Employee::GetSalary };
Employee EmployeeInstanc2 { "Son", "Kim" };
cout << (EmployeeInstance2.*MethodPtr)() << endl;
- std::mem_fn() 을 이용하면
.*
나->*
같은 문법을 사용하지 않아도 된다.
std::function
<functional>
헤더 파일에 정의되어 있는 함수 템플릿이다.- 함수를 가리키는 타입, 함수 객체, 람다 표현식을 비롯한 호출 가능한 모든 대상을 가리키는 타입을 생성할 수 있다.
- 그래서 다형성 함수 래퍼(polymorphic function wrapper)라고도 부른다.
std::function<R(ArgTypes...)>
- R은 리턴 타입, ArgTypes는 매개변수 타입 목록이다.
void SomeFunc(int Num1, string_view String1) { ... }
int main()
{
function<void(int, string)> F1 { SomeFunc };
F1(1, "test");
// CTAD
function F2 { SomeFunc };
// auto 키워드 사용
// 이 경우 컴파일러는 타입을 std::function이 아닌
// 함수 포인터 void (*f1) (int, string_view)로 변환한다.
auto F1 { SomeFunc }
}
- std::function 타입은 함수 포인터처럼 작동하므로 콜백을 받는 함수에 전달 가능하다.
- 이전의 예제에서..
using Matcher = function<bool(int, int)>;
- FindMatches() 가 콜백 매개변수를 받게 하기 위해 function을 이용하지 않고 함수 템플릿을 이용하는 것이 더 바람직하긴 하다.
template<typename Matcher, typename MatchHandler>
void FindMatches(vector<int> Values1, vector<int> Values2,
Matcher CallbackMatcher, MatchHandler CallbackMatchHandler)
{ ... }
// c++20 의 축약 함수 템플릿
void FindMatches(vector<int> Values1, vector<int> Values2,
auto CallbackMatcher, auto CallbackMatchHandler)
{ ... }
함수 객체
- 함수 객체(funciton object)는 펑터(functor)라고도 불린다.
- 클래스의 함수 호출 연산자를 오버로드해 함수 포인터처럼 사용하게 만든 객체.
- 일반 함수 대신 함수 객체를 사용하면 호출 사이에 상태를 유지할 수 있다는 장점이 있다.
간단한 예제
- 함수 호출 연산자를 오버로드해 클래스를 함수 객체로 만들어 보자
class IsLargerThan
{
public:
IsLargerTan(int Value) : m_Value { Value } {}
bool operator()(int Value1, int Value2) const
{
return Value1 > m_Value && Value2 > m_Value;
}
};
int main()
{
vector Values1 { 1, 2, 3};
vector Values2 { 2, 4, 6};
FindMatches(Values1, Values2, IsLargerThan {2}, PrintMatch);
}
- 함수 호출 연산자를 const로 지정해 주는 것이 좋다.
표준 라이브러리의 함수 객체
- 표준 라이브러리의 알고리즘 중 일부는 함수 포인터나 펑터 등과 같은 콜백 형태의 매개변수를 통해 알고리즘의 동작 변경이 가능하다.
- 이를 위한 여러 펑터 클래스가
<functional>
에 정의되어 있다.산술 함수 객체
- plus, minus, multiplies, divides, modulus, negate 에 대한 펑터 클래스 템플릿이 제공된다.
- 이 클래스 템플릿들을 피연산자의 타입으로 템플릿화해 클래스를 만들면 실제 연산자에 대한 래퍼로 활용이 가능하다.
plus<int> MyPlus;
int Result = MyPlus(4, 5);
// Result : 9;
- 위의 예시보다는 다른 함수에 콜백 형태로 전달하기에 좋다.
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 acuumulated;
}
double GeometricMean(vector<int> Values)
{
auto Mult = accumulateData(cbegin(Values), cend(Values),
1, multiplies<int>{} );
return pow(mult, 1.0 / Values.size());
}
- multiplies
{} 는 multiplies 펑터 클래스 템플릿으로부터 int 타입에 대한 인스턴스를 새로 만든다는 의미다.
투명 연산자 펑터(transparent operator functor)
- 템플릿 타입 인수를 생략해도 된다.
- multiplies
{} 를 multiplies {} 의 축약인 multiplies<>{} 로 표현 가능하다. - 이종 타입을 지원하므로, 데이터 손실을 방지하는 등 성능을 더 높일 수 있다.
- 웬만해서는 투명 연산자 펑터를 사용하자
비교 함수 객체
- equal_to, not_equal_to, less, greater, less_equal, greater_equal 등이 있다.
- priority_queue의 디폴트 비교 연산자는 less 이다.
- greater 로 바꾸고 싶다면 다음과 같이 해준다.
priority_queue<int, vector<int>, greater<>> MyQueue;
- 위처럼 투명 연산자를 사용해 주자.
- 불필요한 string 인스턴스 생성등을 방지해 복제 연산을 막을 수 있다.
- 이를 이종 룩업(heterogeneous lookup)이라고 부른다.
- c++20 부터 unordered_map, unordered_set 과 같은 비정렬 연관 컨테이너에 대해 투명 연산자가 추가 되었다.
- 사용 하는 방법이 약간 다르므로 나중에 내용 추가하자.
논리 함수 객체
- logical_not, logical_and, logical_or를 제공한다.
// 벡터내 bool 값이 모두 true 면 true 반환
accumulateData(begin(FlagsVector), end(FlagsVector), true, logical_and<>{});
비트 연산 함수 객체
- bit_and, bit_or, bit_xor, bit_not
어댑터 함수 객체(adapter function object)
- 모든 호출 가능 타입(callable)에 적용 가능
- 미약하게나마 함수 합성(function composition)을 지원해 여러 함수를 하나로 합쳐 원하는 기능 구현이 가능하다.
바인더(binder) (내용 추가하자)
- 콜러블의 매개변수를 일정한 값으로 바인딩할 수 있다.
<functional>
에 정의된 std::bind()를 이용하면 된다.- bind()의 리턴 값은 컴파일러마다 다르므로 auto를 사용해주자
void SomeFunc(int Num1, string_view String1)
{
cout << format("func({} {})", Num1, String1) << endl;
}
string MyString = "abc";
auto F1 = bind(SomeFunc, placeholders::_1, MyString);
F1(16);
// func(16, abc)
// 인수 변경도 가능하다.
auto F2 = bind(SomeFunc, placeholders::_2, placeholders::_1);
F2("test", 32);
// func(32, test)
부정 연산자(내용 추가하기)
멤버 함수 호출하기(내용 추가하기)
람다 표현식
- 람다 표현식을 이용해 익명 함수로 작성하면 다른 이름과 충돌하지 않고, 코드를 간결하게 작성 가능하다.
- 람다 표현식은 모든 로직을 한 곳에 모아둘 수 있어 코드를 이해하고 관리하기 편하다.
문법
- 람다 선언자(lambda introducerr, 람다소개자)라 부르는 대괄호[] 로 시작하고, 그 뒤에 람다 표현식의 본문을 담는 중괄호 {} 가 나온다.
auto BasicLambda { []{cout << "Hello" << endl; } };
BasicLambda();
// Hello
- 컴파일러는 모든 람다 표현식을 자동으로 함수 객체로 변환하는데, 이를 lambda closer라고 부르며, 컴파일러가 생성한 고유한 이름을 갖는다.
- 인수를 받을 수도 있고, 값을 리턴할 수도 있다.
- 리턴 타입은 trailing return type이라 부르는 화살표(->)로 표기 가능한데, 일반 함수의 리턴 타입 추론 규칙에 따라 생략 가능하다.
auto ParametersLambda {
[](int a, int b) -> int {return a + b;}
};
// 혹은
auto ParametersLambda {
[](int a, int b){return a + b;}
};
int Sum = ParametersLambda(1, 2);
// 컴파일러는 다음과 같이 변환한다.
class CompilerGeneratedName
{
public:
auto operator()(int a, int b) const { return a + b; }
};
- 리턴 타입 추론을 거치면 reference와 const 한정자가 제거된다.
- 이럴 땐 decltype(auto) 사용해 주자
[](const Person& person) -> decltype(auto) { return person.getName(); }
- 람다 표현식에 상위 스코프에 있는 변수를 캡쳐해 상태가 있게(stateful) 만들 수 있다.
- 위의 대괄호 부분을 람다 캡처 블록(capture block)이라 부른다.
- 람다 표현식의 본문에서 사용할 수 있게 변수를 캡처한다는 뜻이다.
- 캡처 블록에 변수 이름을 적으면 값 방식으로 캡처한다.
- 캡처된 변수는 람다 표현식으로부터 변환된 펑터의 데이터 멤버가 되고,
- 값 방식으로 캡처된 변수는 이 펑터의 데이터 멤버에 복제된다.
- 캡처한 변수의 const 속성을 그대로 이어받게 된다.
- 람다표현식에서 람다 클로저는 오버로드한 함수 호출 연산자를 가지는데, 디폴트는 const 이다.
- mutable로 지정하면 함수 호출 연산자를 비 const로 만들 수 있다. 이 경우 매개변수가 없어도 소괄호를 반드시 적어주어야 한다.
double data = 1.23;
auto capturingLambda {
[data] () mutable { data *=2, cout << data << endl;};
}
// 2.46
- 위 코드에서 비 const data 변수를 값으로 캡처했고, 함수 호출 연산자도 mutable에 의해 비 const다.
- 변수 이름 앞에 &를 붙이면 레퍼런스로 값을 캡처한다. 이 경우 람다표현식 호출 전 레퍼런스가 유효한지 반드시 확인해주어야 한다.
- 상위 스코프의 변수를 모두 캡처하는 방법은 다음 두가지가 있다. 이를 캡처 디폴트(capture default)라고 부른다.
- [=] : 스코프에 있는 변수를 모두 값으로 캡처. c++20 이전에는 this 포인터도 캡처
- [&] : 스코프에 있는 변수를 모두 레퍼런스로 캡처
- 변수를 골라 캡처해도 된다.
- 캡처 디폴트 옵션으로 캡처 리스트(capture list)를 지정하면 캡처 방식을 선택할 수도 있다.
- 변수 이름 앞에 &나 =를 붙이려면 반드시 캡처 리스트의 첫 번째 원소를 캡처 디폴트로 지정해야 한다.
- [&x] : 변수 x만 레퍼런스로 캡처
- : 변수 x만 값으로 캡처
- [=, &x, &y] : x와 y는 레퍼런스로 캡처, 나머지는 값으로 캡처
- [&, x] : x는 값으로 캡처, 나머지는 레퍼런스로 캡처
- [this] : 현재 객체를 캡처. 이러면 람다 표현식 내부에서 이 객체에 접근할 때 this->를 붙이지 않아도 된다.
- [*this] : 현재 객체의 복제본 캡처. 람다 표현식 실행 시점에 객체가 살아 있지 않을 때 유용
- 글로벌 변수는 값을 기준으로 캡처하라고 해도 항상 레퍼런스 방식으로 캡처해 버려 값을 변경해 버릴 위험이 있다.
[캡처_블록] <템플릿_매개변수> (매개변수) mutable constexpr
noexcept_지정자 속성 -> 리턴_타입 requires {본문}
- 템플릿 매개변수는 c++20
- constexpr로 지정하면 컴파일 시간에 평가된다. 명시적으로 지정하지 않더라도 일정한 요건을 충족하면 내부적으로 constexpr로 처리된다.
- requires는 c++20 으로 함수 호출 연산자에 대한 템플릿 타입 제약 조건을 추가한다.
람다 표현식을 매개변수로 사용하기
- 람다표현식과 시그니처를 똑같이 지정한 std::function 타입 함수 매개변수를 사용
- 템플릿 타입 매개변수를 사용
FindMatches(Values1, Values2,
[](int Value1, Value2) { return Value == Value2; }, PrintMatch);
제네릭 람다 표현식
- 매개변수의 타입에 auto 타입 추론을 적용할 수 있다.
- 템플릿 인수 추론과 같은 규칙이다.
auto areEqual { [](const auto& Value1, const auto& Value2) {
return Value1 == Value2;
}};
Findmatches(Values1, Values2, areEqual, PrintMatch);
// areEqual은 다음과 같이 변환된다.
class CompilerGeneratedName
{
public:
template <typename T1, typename T2>
auto operator()(const T1& Value1, const T2& Value2) const
{return Value1 == Value2;}
};
람다 캡처 표현식
- lambda capture expression을 이용해 캡처 변수를 원하는 표현식으로 초기화 가능하다.
- 레퍼런스가 아닌 캡처 변수를 캡처 초기자(capture initializer)로 초기화하면 복제 방식으로 생성된다. 즉, const 지정자가 제거된다.
double pi = 3.1415;
auto myLambda = [myCapture = "Pi: ", pi] { cout << myCapture <<endl;};
- unique_ptr 같은 경우는 move()를 이용해 값으로 캡처해야 한다.
람다 표현식 템플릿(c++20)
람다 표현식을 리턴 타입으로 사용하기
- std::function 을 이용하면 함수가 람다 표현식을 리턴하게 만들 수 있다.
function<int(void)> multiplyBy2Lambda(int x)
{
return [x](){ return 2*x; };
}
function<int(void)> fn { multiplyBy2Lambda(5) };
cout << fn() << endl;
auto fn { multiplyBy2lambda(5) };
cout << fn() << endl;
// 함수 리턴 타입 추론
auto multiplyBy2Lambda(int x)
{
return [x](){return 2*x;};
}
- 변수 x는 값으로 캡처하므로 람다 표현식을 리턴하기 전에 람다 표현식 안의 x는 x값의 복제본에 바인딩된다.
- 위에서 람다 표현식은 대부분 이 함수가 끝난 뒤 사용되므로 x를 레퍼런스로 캡처하면 이상한 값을 가리켜 버그가 발생할 수 있다.
람다 표현식을 비평가 문맥에서 사용하기 (c++20)
std::invoke()
<functional>
에 정의된 std::invoke()를 사용하면 모든 종류의 콜러블 객체에 대해 일련의 매개변수를 지정해 호출 가능하다.- 임의의 콜럽를을 호출하는 템플릿 코드를 작성할 때 굉장히 유용하다.
void printMessage(string_view message) { cout << message << endl; }
int main()
{
// 일반 함수 호출
invoke(printMessage, "Hello invoke.");
// 람다 표현식 호출
invoke([](const auto& msg) {cout << msg << endl; }, "Hello invoke.");
// string 인스턴스의 멤버 함수 호출
string meg {"Hello invoke."};
cout << invoke(&stirng::size, msg) << endl;
}
Leave a comment