C++17 std::invoke와 std::apply: INVOKE 규칙과 튜플을 인자로 펼치는 원리
이 글의 핵심
std::invoke가 왜 필요한지(멤버 함수 포인터·함수 객체·자유 함수를 하나의 문법으로 다루는 원리), std::apply가 튜플을 index_sequence로 풀어 함수 인자로 전달하는 방식, invoke_result/is_invocable로 콜러블을 컴파일타임에 검증하는 실전 패턴을 정리합니다.
std::invoke가 왜 필요한가
C++에서 “무언가를 호출 가능한 것(callable)“에는 자유 함수, 함수 포인터, 멤버 함수 포인터, 멤버 데이터 포인터, 함수 객체(람다 포함)까지 여러 종류가 있습니다. 문제는 이들이 서로 다른 호출 문법을 요구한다는 점입니다. 자유 함수는 f(args)로 바로 호출하면 되지만, 멤버 함수 포인터는 (obj.*ptr)(args) 또는 (objPtr->*ptr)(args)처럼 포인터-투-멤버 연산자(.*, ->*)를 별도로 써야 하고, 멤버 데이터 포인터는 호출이 아니라 역참조(obj.*dataPtr)로 접근해야 합니다.
일반적인 코드에서는 이 차이가 크게 문제되지 않습니다. 호출 대상이 무엇인지 코드를 작성하는 시점에 이미 알고 있기 때문입니다. 하지만 템플릿으로 작성하는 제네릭 콜백 래퍼, 로깅/타이밍 데코레이터, 이벤트 디스패처처럼 “호출 대상의 정확한 종류를 모르는 채로 그대로 전달만 받아서 호출해야 하는” 코드에서는 이야기가 달라집니다. template<typename Func, typename... Args> auto call(Func f, Args... args) 같은 함수는 f가 자유 함수인지, 멤버 함수 포인터인지, 함수 객체인지 알 수 없는 상태에서 컴파일되어야 합니다. f(args...)라는 단순한 문법만으로는 멤버 함수 포인터가 넘어왔을 때 컴파일이 되지 않습니다.
std::invoke는 바로 이 간극을 메우기 위해 C++17에 추가되었습니다. 내부적으로는 표준이 정의한 INVOKE 개념(호출 대상의 종류를 컴파일타임에 판별해 알맞은 호출 문법으로 치환하는 규칙 집합)을 그대로 함수화한 것입니다. std::invoke(callable, args...)라고만 쓰면, callable이 무엇이든 컴파일러가 올바른 호출 형태를 선택해줍니다.
#include <functional>
using namespace std;
int add(int a, int b) {
return a + b;
}
struct Calculator {
int multiply(int a, int b) {
return a * b;
}
int value = 10;
};
int main() {
// 일반 함수
cout << invoke(add, 2, 3) << endl; // 5
// 람다
auto lambda = [](int x) { return x * 2; };
cout << invoke(lambda, 5) << endl; // 10
// 멤버 함수
Calculator calc;
cout << invoke(&Calculator::multiply, calc, 3, 4) << endl; // 12
// 멤버 변수
cout << invoke(&Calculator::value, calc) << endl; // 10
}
위 예제에서 자유 함수 add, 람다, 멤버 함수 포인터 &Calculator::multiply, 멤버 데이터 포인터 &Calculator::value가 모두 invoke(callable, args...)라는 동일한 문법으로 호출됩니다. 컴파일러는 callable의 타입을 보고 INVOKE 규칙에 따라 실제 코드를 생성합니다. 멤버 함수 포인터나 멤버 데이터 포인터가 대상이면 첫 번째 인자(여기서는 calc)를 암묵적으로 this처럼 취급해 .* 문법으로 치환하고, 그 외에는 그냥 함수 호출로 치환합니다. 이 과정은 전부 컴파일타임에 결정되므로 런타임 오버헤드는 없습니다.
흔한 설계 실수: 멤버 함수 포인터를 받지 못하는 콜백 래퍼
제네릭 콜백 래퍼를 처음 만들 때는 보통 자유 함수와 람다만 생각해서 내부 호출을 f(args...)로 씁니다. 그러다 누군가 wrapper.register(&Logger::onEvent, logger)처럼 멤버 함수를 등록하려는 순간 컴파일 에러가 납니다. f(args...) 문법은 멤버 함수 포인터를 호출할 수 없기 때문입니다. 흔한 임시방편은 “멤버 함수는 std::bind로 감싸서 넘겨라”인데, 그러면 등록하는 곳마다 bind(&Logger::onEvent, &logger, placeholders::_1)이 반복되어 읽기 어려워집니다. 래퍼 내부의 호출 한 줄을 std::invoke(f, args...)로 바꾸면 자유 함수·람다·멤버 함수 포인터·멤버 데이터 포인터를 등록부에서 구분할 필요 없이 그대로 받을 수 있습니다. 표준 라이브러리의 std::thread, std::function, std::async가 전부 이 INVOKE 규칙을 따르는 이유이기도 하므로, 제네릭 콜백을 새로 설계한다면 처음부터 invoke를 기본으로 두는 편이 낫습니다.
std::apply와 튜플 언팩의 동작 원리
std::apply는 std::tuple(또는 std::pair, std::array처럼 tuple 프로토콜을 만족하는 타입)에 담긴 값들을 함수의 개별 인자로 풀어서 전달합니다. 튜플 안에 몇 개의 원소가 들어 있는지, 각 원소의 타입이 무엇인지는 컴파일타임에 알 수 있으므로, apply는 이 정보를 이용해 내부적으로 std::index_sequence(0부터 N-1까지의 정수 시퀀스를 타입으로 표현한 것)를 생성하고, 그 시퀀스를 파라미터 팩 확장에 사용해 invoke(func, get<0>(tuple), get<1>(tuple), ..., get<N-1>(tuple)) 형태로 치환합니다. 즉 apply(func, tuple)은 내부적으로 invoke를 한 번 더 감싸서 튜플 언팩까지 처리해주는 얇은 계층입니다.
int add(int a, int b, int c) {
return a + b + c;
}
int main() {
tuple<int, int, int> args = {1, 2, 3};
// 튜플 언팩
int result = apply(add, args);
cout << result << endl; // 6
}
이 예제에서 apply(add, args)는 add(get<0>(args), get<1>(args), get<2>(args)), 즉 add(1, 2, 3)으로 치환됩니다. 원소 개수와 순서가 함수의 매개변수 시그니처와 정확히 일치해야 하는 이유가 여기 있습니다. 컴파일러가 index_sequence를 이용해 기계적으로 위치별로 언팩하기 때문에, 이름이나 의미가 아니라 순서만 보고 대응시킵니다.
실전 예시
예시 1: 제네릭 콜백
로깅이나 타이밍 측정을 함수 호출 앞뒤에 끼워 넣고 싶을 때, 호출 대상이 무엇이든 상관없이 감쌀 수 있는 래퍼를 만들 수 있습니다.
template<typename Func, typename... Args>
auto callWithLogging(Func&& func, Args&&... args) {
cout << "함수 호출 시작" << endl;
auto result = invoke(forward<Func>(func), forward<Args>(args)...);
cout << "함수 호출 완료" << endl;
return result;
}
int main() {
auto result = callWithLogging(add, 2, 3);
cout << "결과: " << result << endl;
}
forward와 함께 쓰인 이유는 완벽 전달(perfect forwarding)을 유지하기 위해서입니다. func와 args가 좌변값인지 우변값인지에 따라 그 성질을 그대로 invoke로 전달해야, 불필요한 복사 없이 원래 호출과 동일한 성능을 낼 수 있습니다. 이런 패턴은 프로파일링 훅, 재시도(retry) 로직, 트랜잭션 경계 지정처럼 “임의의 함수 호출을 가로채서 전후 처리를 추가하는” 모든 데코레이터 스타일 코드에 그대로 적용됩니다.
예시 2: 멤버 함수 래퍼
template<typename T, typename Func, typename... Args>
auto callMember(T& obj, Func func, Args&&... args) {
return invoke(func, obj, forward<Args>(args)...);
}
class Widget {
public:
void setName(const string& name) {
this->name = name;
cout << "이름 설정: " << name << endl;
}
string getName() const {
return name;
}
private:
string name;
};
int main() {
Widget w;
callMember(w, &Widget::setName, "MyWidget");
string name = callMember(w, &Widget::getName);
cout << name << endl;
}
callMember는 첫 번째 인자로 객체, 두 번째 인자로 멤버 함수 포인터를 받아 invoke에 그대로 넘깁니다. invoke가 멤버 함수 포인터를 인식하면 두 번째 인자(여기서는 obj)를 암묵적 this로 사용해 (obj.*func)(args...) 형태로 치환합니다. 이 패턴은 옵저버/이벤트 시스템에서 “어떤 객체의 어떤 멤버 함수를 호출할지”를 런타임에 등록받아 저장해뒀다가 나중에 호출하는 디스패처 구현에 자주 쓰입니다.
예시 3: 튜플 기반 함수 호출
template<typename Func, typename Tuple>
auto callWithTuple(Func&& func, Tuple&& args) {
return apply(forward<Func>(func), forward<Tuple>(args));
}
int multiply(int a, int b, int c) {
return a * b * c;
}
int main() {
auto args = make_tuple(2, 3, 4);
int result = callWithTuple(multiply, args);
cout << result << endl; // 24
}
인자 집합을 튜플로 묶어서 전달하는 스타일은, 인자 개수가 가변적이거나 인자 집합 자체를 데이터처럼 저장·전달해야 하는 상황(태스크 큐, RPC 스텁, 커맨드 패턴)에서 유용합니다. 호출 시점과 인자를 준비하는 시점이 분리될 때, 함수 포인터와 튜플을 한 쌍으로 저장해두었다가 나중에 apply로 실행하는 식입니다.
예시 4: 지연 실행
template<typename Func, typename... Args>
class DeferredCall {
private:
Func func;
tuple<Args...> args;
public:
DeferredCall(Func f, Args... a) : func(f), args(a...) {}
auto execute() {
return apply(func, args);
}
};
template<typename Func, typename... Args>
auto defer(Func func, Args... args) {
return DeferredCall(func, args...);
}
int main() {
auto deferred = defer(add, 2, 3);
cout << "나중에 실행..." << endl;
int result = deferred.execute();
cout << "결과: " << result << endl;
}
DeferredCall은 함수와 인자를 묶어 저장해두었다가 원하는 시점에 execute()로 실행하는 얇은 클로저 대체재입니다. 람다로도 동일한 효과를 낼 수 있지만, 이 방식은 저장된 인자 각각에 개별적으로 접근하거나 검사해야 하는 경우(예: 로깅 시 인자 값을 출력하거나, 재시도 시 특정 인자만 갱신하는 경우)에 람다 캡처보다 다루기 쉽습니다.
컴파일은 되는데 틀리는 경우: apply와 튜플 순서
apply는 튜플 원소를 이름이 아니라 순서로 매개변수에 넣습니다. 커맨드 큐에 좌표를 tuple<int, int, int>로 저장했다가 apply(moveTo, coords)로 호출하는 코드에서, 튜플을 만드는 쪽이 리팩터링 중에 make_tuple(z, x, y)로 바뀌어도 moveTo(int x, int y, int z)와 타입이 모두 int라 컴파일러는 아무 말도 하지 않습니다. 좌표가 뒤섞인 채 실행되고, 튜플을 만드는 곳과 푸는 곳이 멀리 떨어져 있을수록 원인을 찾기 어렵습니다. 같은 타입이 여러 개 이어지는 인자 묶음은 struct Point { int x, y, z; }처럼 이름 있는 타입으로 전달하는 것이 근본적인 해결이고, 튜플을 꼭 써야 한다면 생성하는 곳 바로 옆에 원소 순서를 주석으로 남기거나 apply를 부르는 쪽에서 구조적 바인딩(auto [x, y, z] = coords;)으로 풀어 이름을 붙인 뒤 호출합니다.
make_from_tuple: 튜플로 객체 생성하기
std::make_from_tuple<T>(tuple)은 apply의 생성자 버전입니다. 튜플 원소를 풀어 T의 생성자에 넘깁니다.
struct Point {
int x, y;
Point(int x, int y) : x(x), y(y) {}
};
auto p = std::make_from_tuple<Point>(std::make_tuple(10, 20)); // Point{10, 20}
struct Agg { int x, y; }; // 생성자 없는 집합체
auto a = std::make_from_tuple<Agg>(std::make_tuple(1, 2)); // C++17: 에러, C++20: OK
make_from_tuple은 내부적으로 T(args...)처럼 괄호로 생성하므로, C++17에서는 생성자가 없는 집합체를 만들 수 없습니다(GCC 10 -std=c++17에서 no matching function for call to 'Agg::Agg(...)'). C++20부터는 괄호로도 집합체를 초기화할 수 있게 되어(P0960) 같은 코드가 컴파일됩니다. 설정값을 튜플로 읽어 구조체를 만드는 코드에서 표준 버전에 따라 되고 안 되는 대표적인 경우입니다.
apply·invoke에 넘길 수 없는 것들
void log(int);
void log(double);
std::apply(log, std::make_tuple(1)); // ❌ no matching function for call to 'apply(<unresolved overloaded function type>, ...)'
std::apply([](auto&&... xs) { log(std::forward<decltype(xs)>(xs)...); }, std::make_tuple(1)); // ✅
오버로드된 함수 이름이나 함수 템플릿 이름은 그 자체로 타입이 없어서 apply·invoke·std::thread에 그대로 넘길 수 없습니다. 람다로 한 번 감싸면 호출 시점에 오버로드 해석이 일어나 해결됩니다. 또 참조로 수정하려면 튜플에 참조를 담아야 합니다. std::make_tuple(x)는 값을 복사하므로 apply로 넘긴 람다가 int&로 받아 고쳐도 원본 x는 그대로이고, std::forward_as_tuple(x)나 std::tie(x)로 만든 튜플이어야 원본이 바뀝니다. 다만 forward_as_tuple은 임시 객체까지 참조로 잡으므로, 그 튜플을 변수에 저장해 두었다가 나중에 apply하면 댕글링 참조가 되니 같은 식 안에서 바로 쓰는 용도로만 씁니다.
invoke_result와 is_invocable로 컴파일타임 검사하기
제네릭 라이브러리를 작성하다 보면 “이 타입이 이런 인자로 호출 가능한가”, “호출한다면 반환 타입은 무엇인가”를 컴파일타임에 알아야 할 때가 있습니다. std::invoke_result_t는 실제로 invoke를 호출했을 때의 반환 타입을 계산해주고, std::is_invocable(또는 is_invocable_r)은 특정 시그니처로 호출 가능한지를 true_type/false_type으로 알려줍니다.
template<typename Func, typename... Args>
void printReturnType(Func func, Args... args) {
using ReturnType = invoke_result_t<Func, Args...>;
if constexpr (is_same_v<ReturnType, void>) {
cout << "반환 타입: void" << endl;
} else if constexpr (is_integral_v<ReturnType>) {
cout << "반환 타입: 정수" << endl;
} else {
cout << "반환 타입: 기타" << endl;
}
}
int main() {
printReturnType(add, 1, 2); // 정수
}
이 기법의 실전 용도는 콜백 시그니처 검증입니다. 예를 들어 이벤트 라이브러리에서 사용자가 등록하는 핸들러가 void(const Event&) 형태를 만족하는지 static_assert(is_invocable_v<Handler, const Event&>, "핸들러 시그니처가 맞지 않습니다")로 미리 검사해두면, 실제로 이벤트가 발생하는 런타임 시점이 아니라 핸들러를 등록하는 컴파일 시점에 바로 에러 메시지를 받을 수 있습니다. 템플릿 인스턴스화 실패로 인한 길고 알아보기 힘든 에러 메시지 대신, 의도가 담긴 명확한 static_assert 메시지를 보여줄 수 있다는 점에서 라이브러리 사용성에 큰 차이를 만듭니다.
멤버 포인터를 invoke로 다루기
struct Widget {
int value;
void setValue(int v) {
value = v;
}
int getValue() const {
return value;
}
};
int main() {
Widget w;
// 멤버 함수 포인터
auto setFunc = &Widget::setValue;
invoke(setFunc, w, 42);
// 멤버 변수 포인터
auto valuePtr = &Widget::value;
cout << invoke(valuePtr, w) << endl; // 42
}
멤버 데이터 포인터를 invoke로 다루는 부분은 언뜻 이상하게 보일 수 있습니다. invoke(valuePtr, w)는 함수 호출이 아니라 w.*valuePtr과 동일한 멤버 접근으로 치환됩니다. INVOKE 규칙이 “멤버 포인터 계열은 첫 번째 인자를 대상 객체로 취급한다”는 원칙을 함수 포인터와 데이터 포인터 모두에 일관되게 적용하기 때문입니다. 제네릭 코드에서 “이 멤버 포인터가 함수인지 데이터인지”를 매번 분기하지 않아도 되는 것이 이 설계의 핵심 이점입니다.
왜 표준 라이브러리 내부가 INVOKE 규칙을 따르는가
C++17 이후 std::thread의 생성자, std::function, std::bind, 그리고 <algorithm>의 대부분의 알고리즘(for_each, transform, sort의 비교자 등)은 사용자가 넘긴 콜러블을 호출할 때 표준이 정의한 INVOKE 개념을 따르도록 명시되어 있습니다. 이는 std::invoke가 도입되기 전에도 이미 존재하던 관례였지만, C++17에서 std::invoke가 표준화되면서 “표준 라이브러리가 콜러블을 호출하는 방식”과 “사용자가 제네릭 코드에서 콜러블을 호출하는 방식”이 하나의 함수로 통일됐습니다.
flowchart TD
A["콜러블 전달: 자유 함수 / 람다 / 멤버 함수 포인터 / 멤버 데이터 포인터"] --> B{"invoke 규칙 판별"}
B -->|"자유 함수·함수 객체"| C["f(args...)"]
B -->|"멤버 함수 포인터 + 객체/포인터"| D["(obj.*f)(args...)"]
B -->|"멤버 데이터 포인터 + 객체/포인터"| E["obj.*f"]
C --> F["std::thread, std::function,<br/>std::sort 비교자 등에서 동일하게 사용"]
D --> F
E --> F
실무적으로 이 사실이 중요한 이유는, std::thread(&Logger::onEvent, &logger, event)처럼 스레드 생성자에 멤버 함수 포인터와 객체 포인터를 바로 넘길 수 있는 것도, std::sort(v.begin(), v.end(), &Item::priority)처럼 멤버 데이터 포인터를 비교자로 바로 넘길 수 있는 것(C++20 range 알고리즘의 projection에서 특히 자주 씀)도 전부 같은 INVOKE 규칙 덕분이기 때문입니다. std::invoke를 직접 호출하지 않더라도, 표준 라이브러리를 쓰는 한 이 규칙의 혜택을 이미 받고 있는 셈입니다. 반대로 직접 제네릭 코드를 작성할 때 f(args...)로 단순하게 호출하면, 표준 라이브러리와 다른 규칙으로 동작하는 비일관적인 API가 되기 쉽습니다.
자주 발생하는 문제
문제 1: 멤버 함수 호출
// ❌ 직접 호출 불가
auto func = &Widget::getValue;
// func(); // 에러
// ✅ invoke 사용
Widget w;
invoke(func, w);
멤버 함수 포인터는 그 자체로는 호출 가능한 객체가 아닙니다. 반드시 대상 객체(또는 객체 포인터)와 함께 .*/->* 연산자를 거치거나 invoke를 통해야 호출됩니다. 이 사실을 모르면 “함수 포인터인데 왜 괄호로 호출이 안 되는가”라는 질문으로 이어지기 쉽습니다.
문제 2: apply 인자 순서
// ❌ 순서 중요
int subtract(int a, int b) {
return a - b;
}
auto args = make_tuple(3, 10);
cout << apply(subtract, args) << endl; // -7 (3 - 10)
// ✅ 순서 확인
auto args2 = make_tuple(10, 3);
cout << apply(subtract, args2) << endl; // 7 (10 - 3)
앞서 겪은 좌표 버그와 같은 종류의 문제입니다. apply는 튜플 원소를 위치 순서대로만 대응시키므로, 튜플을 만드는 코드와 함수 시그니처가 코드베이스 안에서 멀리 떨어져 있을수록 순서 불일치를 눈으로 잡아내기 어려워집니다.
문제 3: 참조 캡처
// ❌ 복사
int x = 10;
auto args = make_tuple(x);
x = 20;
apply([](int y) { cout << y << endl; }, args); // 10
// ✅ 참조
auto args2 = make_tuple(ref(x));
x = 20;
apply([](int y) { cout << y << endl; }, args2); // 20
make_tuple은 기본적으로 값을 복사해서 저장합니다. 튜플이 만들어지는 시점의 값이 스냅샷처럼 고정되기 때문에, 이후 원본 변수가 바뀌어도 튜플 안의 값은 그대로 남습니다. 나중 시점의 값을 반영하고 싶다면 std::ref로 감싸 참조를 저장해야 하며, 이때는 참조 대상의 수명이 apply 호출 시점까지 유효한지 반드시 확인해야 합니다. 특히 지연 실행 패턴(DeferredCall처럼)에서 지역 변수를 ref로 캡처해뒀다가 스코프를 벗어난 뒤 실행하면 댕글링 참조가 됩니다.
invoke vs 직접 호출
// 직접 호출
add(2, 3);
calc.multiply(3, 4);
// invoke (제네릭)
invoke(add, 2, 3);
invoke(&Calculator::multiply, calc, 3, 4);
호출 대상을 코드 작성 시점에 이미 알고 있는 일반적인 코드에서는 invoke를 쓸 이유가 없습니다. 오히려 익숙하지 않은 팀원에게는 가독성을 해치는 선택일 수 있습니다. invoke가 진가를 발휘하는 곳은 어디까지나 호출 대상의 종류가 템플릿 파라미터로 넘어오는 제네릭 코드입니다.
invoke 장점:
- 자유 함수·멤버 함수 포인터·멤버 데이터 포인터·함수 객체를 하나의 문법으로 처리
- 완벽 전달(forwarding)과 결합해 불필요한 복사 없이 콜백 래핑 가능
- 표준 라이브러리(
std::thread,std::function, 알고리즘)와 동일한 호출 규칙을 따르므로 API 일관성 유지
invoke·apply가 필요해지는 시점과 apply의 함정
std::invoke와 std::apply는 화려한 기능이라기보다는, 이미 표준 라이브러리 내부에서 쓰이던 “콜러블을 균일하게 다루는 규칙”을 사용자 코드에서도 그대로 쓸 수 있게 열어준 유틸리티입니다. 직접 코드를 작성할 때는 체감이 크지 않지만, 콜백을 받아서 전달만 하는 제네릭 코드(데코레이터, 이벤트 디스패처, 태스크 큐, 리트라이 로직)를 작성하는 순간부터는 없어서는 안 될 도구가 됩니다. 반대로 apply처럼 위치 기반으로 인자를 매칭하는 API는 튜플 순서와 함수 시그니처가 어긋나도 컴파일러가 잡아주지 못한다는 함정이 있으므로, 같은 타입이 반복되는 인자 집합에는 이름 있는 구조체를 우선 고려하는 편이 안전합니다.
FAQ
Q1: invoke는 언제 사용하나요?
A:
- 호출 대상의 종류(자유 함수/멤버 함수/함수 객체)를 미리 알 수 없는 제네릭 콜백
- 멤버 함수 포인터를 그대로 전달받아 호출해야 하는 래퍼
- 표준 라이브러리와 동일한 호출 규칙을 유지하고 싶은 API 설계
Q2: apply는 언제 사용하나요?
A:
- 튜플에 저장해둔 인자를 함수에 그대로 언팩해 호출할 때
- 가변 인자를 데이터처럼 저장했다가 나중에 실행하는 지연 실행 패턴
- RPC 스텁, 태스크 큐처럼 인자 집합 자체를 값으로 다뤄야 하는 경우
Q3: 성능 오버헤드는?
A: invoke와 apply는 템플릿과 constexpr 기반으로 구현되어 있어 대부분의 컴파일러에서 인라인화되며, 직접 호출과 동일한 코드로 최적화됩니다. 단, 디버그 빌드나 최적화 옵션이 낮은 환경에서는 함수 호출 계층이 그대로 남아 미세한 차이가 날 수 있으니, 성능이 중요한 구간이라면 릴리스 빌드에서 어셈블리를 확인해보는 것이 좋습니다.
Q4: invoke vs 직접 호출?
A: 호출 대상을 코드에서 이미 알고 있다면 직접 호출이 더 읽기 쉽습니다. 제네릭 코드에서만 invoke를 사용하세요.
Q5: apply vs 가변 인자 템플릿?
A:
- apply: 인자가 이미 튜플 형태로 묶여 저장되어 있을 때 풀어서 호출
- 가변 인자 템플릿: 호출 시점에 인자를 개별적으로 바로 전달할 때
Q6: invoke/apply 학습 리소스는?
A:
- cppreference.com의
std::invoke,std::apply문서 - “C++17: The Complete Guide” (Nicolai Josuttis)
- “Effective Modern C++” (Scott Meyers)