9 minute read

‘전문가를 위한 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 으로 함수 호출 연산자에 대한 템플릿 타입 제약 조건을 추가한다.

람다 표현식을 매개변수로 사용하기

  1. 람다표현식과 시그니처를 똑같이 지정한 std::function 타입 함수 매개변수를 사용
  2. 템플릿 타입 매개변수를 사용
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