C++ std::bind와 placeholders: 부분 적용, 멤버 함수 바인딩, 람다로 바꿀 때
이 글의 핵심
bind는 기본적으로 인자를 복사하기 때문에 참조를 넘기려면 std::ref가 필요하고, 중첩 bind나 placeholder 순서는 읽는 사람을 쉽게 헷갈리게 합니다. 성능 오버헤드와 가독성 측면에서 람다가 대부분의 경우 더 나은 이유를 짚고, 기존 코드에서 bind를 만났을 때 이해하고 옮기는 방법을 정리했습니다.
bind란?
std::bind 는 C++11에서 도입된 함수로, 함수와 인자를 미리 바인딩하여 새로운 함수 객체를 생성합니다. 부분 적용(Partial Application), 인자 재배치, 멤버 함수 바인딩 등에 사용됩니다.
왜 필요한가?:
- 부분 적용: 일부 인자를 미리 고정
- 인자 재배치: 인자 순서 변경
- 멤버 함수: 멤버 함수를 일반 함수처럼 사용
- 콜백: 콜백 함수 생성
// ❌ 직접 구현: 번거로움
class Add5 {
int fixed_;
public:
Add5(int fixed) : fixed_(fixed) {}
int operator()(int x) const { return fixed_ + x; }
};
Add5 add5(5);
add5(10); // 15
// ✅ bind: 간결
auto add5 = std::bind(add, 5, std::placeholders::_1);
add5(10); // 15
C++11 이전에는 이런 “일부 인자가 고정된 함수”를 만들려면 위의 Add5처럼 함수 객체 클래스를 직접 정의하거나, std::bind1st/std::bind2nd 같은 제약 많은 어댑터를 써야 했습니다. bind1st와 bind2nd는 인자가 정확히 두 개인 함수에만 쓸 수 있었고 C++11에서 deprecated, C++17에서 제거되었습니다. std::bind는 Boost.Bind를 표준으로 옮긴 것으로, 인자 개수와 관계없이 어떤 위치든 고정하거나 재배치할 수 있게 일반화한 도구입니다.
다만 std::bind와 람다가 같은 C++11에 함께 들어왔고, C++14에서 람다가 초기화 캡처와 auto 매개변수를 지원하면서 bind가 필요한 경우는 크게 줄었습니다. 이 글은 bind의 동작을 이해해 기존 코드를 읽고 고칠 수 있게 하는 데 초점을 두고, 각 예제에서 람다로 쓰면 어떻게 되는지도 함께 설명합니다.
기본 사용법
#include <functional>
using namespace std;
using namespace placeholders;
int add(int a, int b) {
return a + b;
}
int main() {
// 부분 적용
auto add5 = bind(add, 5, _1);
cout << add5(10) << endl; // 15
cout << add5(20) << endl; // 25
}
bind(add, 5, _1)은 “나중에 호출될 때 첫 번째 인자는 5로, 두 번째 인자는 호출 시 받은 첫 번째 값으로 채워서 add를 부르는” 함수 객체를 돌려줍니다. 이 반환 타입은 표준이 이름을 정하지 않은 구현 정의 타입이라 auto로 받거나 std::function에 담아야 합니다. using namespace placeholders;는 _1, _2를 짧게 쓰려고 넣은 것인데, 헤더 파일에서 이렇게 쓰면 포함하는 모든 파일로 이름이 퍼지므로 .cpp 파일이나 함수 안에서만 쓰는 편이 좋습니다.
bind의 동작 원리:
// 개념적 구현
template<typename Func, typename... BoundArgs>
class BindExpression {
Func func_;
std::tuple<BoundArgs...> boundArgs_;
public:
BindExpression(Func func, BoundArgs... args)
: func_(func), boundArgs_(args...) {}
template<typename... CallArgs>
auto operator()(CallArgs&&... args) {
// boundArgs와 args를 조합하여 func 호출
return std::apply(func_, /* 조합된 인자 */);
}
};
핵심은 바인딩한 인자를 생성 시점에 복사(또는 이동)해서 튜플에 저장한다는 점입니다. 호출할 때는 저장된 값 중 placeholder 자리만 실제 호출 인자로 바꿔 끼워 원래 함수를 부릅니다. 이 복사 동작 때문에 참조를 의도했는데 값이 복사되는 문제(아래 “문제 1”)가 생기고, 복사가 비싼 객체를 바인딩하면 bind 객체를 만들 때마다 그 비용이 듭니다.
실제 구현에는 몇 가지 특별 규칙이 더 있습니다. 바인딩한 인자가 std::reference_wrapper면 참조로 풀어서 넘기고, 바인딩한 인자가 또 다른 bind 표현식이면 호출 시점에 그것을 먼저 평가한 결과를 넘깁니다(중첩 bind). 그리고 호출할 때 placeholder가 가리키지 않는 남는 인자는 조용히 버려집니다. add5(10, 20, 30)처럼 인자를 더 넘겨도 컴파일 에러가 나지 않는데, 람다라면 즉시 에러가 날 실수를 bind는 숨겨버립니다.
placeholders
placeholder _N은 “호출 시 받은 N번째 인자”를 뜻하며, 함수 매개변수 위치와는 관계가 없습니다. 이 점이 처음에 가장 헷갈리는 부분입니다.
int subtract(int a, int b) {
return a - b;
}
int main() {
// 인자 순서 그대로
auto f1 = bind(subtract, _1, _2);
cout << f1(10, 3) << endl; // 7
// 인자 순서 바꾸기
auto f2 = bind(subtract, _2, _1);
cout << f2(10, 3) << endl; // -7 (3 - 10)
// 고정 인자
auto f3 = bind(subtract, 100, _1);
cout << f3(30) << endl; // 70
}
f2는 subtract(_2, _1)이므로 f2(10, 3)을 호출하면 두 번째 인자 3이 a에, 첫 번째 인자 10이 b에 들어가 3 - 10이 됩니다. 같은 placeholder를 여러 번 쓰는 것도 가능해서 bind(multiply, _1, _1)은 제곱 함수가 됩니다. 반대로 _2만 쓰고 _1을 쓰지 않으면 첫 번째 인자는 받기만 하고 버려집니다. 표준은 최소 _1부터 _10까지 제공하도록 요구하고, 구현은 그보다 더 많이 제공할 수 있습니다.
멤버 함수
비정적 멤버 함수는 숨겨진 첫 번째 인자로 this를 받는 함수로 볼 수 있습니다. 그래서 bind로 멤버 함수를 묶을 때는 두 번째 인자로 호출 대상 객체(포인터, 참조, 스마트 포인터 모두 가능)를 넘겨야 합니다.
class Calculator {
public:
int multiply(int a, int b) const {
return a * b;
}
int value = 10;
};
int main() {
Calculator calc;
// 멤버 함수 바인딩
auto f = bind(&Calculator::multiply, &calc, _1, _2);
cout << f(3, 4) << endl; // 12
// 멤버 변수 바인딩
auto getValue = bind(&Calculator::value, &calc);
cout << getValue() << endl; // 10
}
&calc처럼 포인터를 넘기면 bind 객체는 포인터만 복사해 두므로, calc가 먼저 소멸하면 이후 호출은 댕글링 포인터를 통한 미정의 동작입니다. 반대로 calc를 값으로 넘기면(bind(&Calculator::multiply, calc, _1, _2)) 객체 전체가 복사되어 원본을 수정해도 bind 객체 쪽에는 반영되지 않습니다. 콜백을 오래 보관해야 하는 상황이라면 std::shared_ptr<Calculator>를 넘겨 수명을 함께 묶거나, std::weak_ptr로 받아 호출 시점에 객체가 살아 있는지 확인하는 방식을 씁니다.
멤버 함수가 오버로드되어 있으면 &Calculator::multiply만으로는 어느 함수인지 정할 수 없어 no matching function for call to 'bind(<unresolved overloaded function type>, ...)' 같은 에러가 납니다. 이때는 static_cast<int (Calculator::*)(int, int) const>(&Calculator::multiply)처럼 멤버 함수 포인터 타입으로 캐스팅해야 하는데, 이 번거로움이 람다를 선호하는 큰 이유 중 하나입니다. 람다 안에서는 평범한 호출 문법으로 쓰므로 오버로드 해석이 자연스럽게 이루어집니다.
실전 예시
예시 1: 이벤트 핸들러
class Button {
private:
function<void()> onClick;
public:
void setOnClick(function<void()> handler) {
onClick = handler;
}
void click() {
if (onClick) {
onClick();
}
}
};
class App {
public:
void handleClick(const string& buttonName) {
cout << buttonName << " 클릭됨" << endl;
}
};
int main() {
App app;
Button btn;
// 멤버 함수 바인딩
btn.setOnClick(bind(&App::handleClick, &app, "버튼1"));
btn.click(); // "버튼1 클릭됨"
}
Button은 App이라는 타입을 전혀 모르고 std::function<void()>만 알고 있습니다. bind가 “어느 객체의 어느 멤버 함수를 어떤 인자로 부를지”를 하나의 호출 가능 객체로 포장해 주기 때문에 두 클래스의 결합도가 낮아집니다. 같은 코드를 람다로 쓰면 btn.setOnClick([&app] { app.handleClick("버튼1"); });입니다. 두 방식 모두 app의 주소만 보관하므로, Button이 App보다 오래 살아남으면 클릭 시점에 소멸한 객체를 호출하게 됩니다. GUI 프레임워크에서 창을 닫은 뒤 늦게 도착한 이벤트가 크래시를 일으키는 전형적인 원인이 이것이라, 핸들러를 등록한 객체가 소멸할 때 핸들러도 해제하는 규칙을 함께 두어야 합니다.
예시 2: 부분 적용
int power(int base, int exponent) {
int result = 1;
for (int i = 0; i < exponent; i++) {
result *= base;
}
return result;
}
int main() {
// 제곱 함수
auto square = bind(power, _1, 2);
cout << square(5) << endl; // 25
// 세제곱 함수
auto cube = bind(power, _1, 3);
cout << cube(5) << endl; // 125
// 2의 거듭제곱
auto powerOf2 = bind(power, 2, _1);
cout << powerOf2(10) << endl; // 1024
}
같은 power 함수에서 고정하는 인자 위치만 바꿔 square, cube, powerOf2라는 서로 다른 함수를 만들었습니다. 이것이 부분 적용의 전형적인 쓰임입니다. 이 경우 람다 [](int b) { return power(b, 2); }와 비교해 bind 쪽이 특별히 짧지도 않고, 호출할 때 square(5, 99)처럼 인자를 잘못 더 넘겨도 bind는 남는 인자를 버리고 조용히 25를 돌려준다는 점을 기억해 둘 만합니다.
예시 3: 필터 조합
bool inRange(int value, int min, int max) {
return value >= min && value <= max;
}
int main() {
vector<int> v = {1, 5, 10, 15, 20, 25, 30};
// 10-20 범위 필터
auto filter = bind(inRange, _1, 10, 20);
auto it = find_if(v.begin(), v.end(), filter);
if (it != v.end()) {
cout << "첫 매칭: " << *it << endl; // 10
}
// 모두 찾기
for (int x : v) {
if (filter(x)) {
cout << x << " "; // 10 15 20
}
}
}
find_if는 인자 하나를 받는 조건자(predicate)를 요구하는데, inRange는 인자가 세 개입니다. bind로 범위 값 두 개를 고정해 인자 하나짜리 조건자로 바꾼 것입니다. 실무에서는 범위 값이 런타임에 정해지는 경우가 많은데, 람다로 쓰면 [lo, hi](int v) { return inRange(v, lo, hi); }처럼 어떤 값이 캡처되는지가 캡처 목록에 드러나 읽기가 더 쉽습니다.
예시 4: 콜백 시스템
class Timer {
private:
function<void()> callback;
public:
void setCallback(function<void()> cb) {
callback = cb;
}
void trigger() {
if (callback) {
callback();
}
}
};
class Logger {
public:
void log(const string& level, const string& message) {
cout << "[" << level << "] " << message << endl;
}
};
int main() {
Timer timer;
Logger logger;
// 부분 적용
timer.setCallback(bind(&Logger::log, &logger, "INFO", "타이머 실행"));
timer.trigger(); // [INFO] 타이머 실행
}
여기서 "INFO"와 "타이머 실행"은 const char*로 저장되었다가 호출 시점에 const string& 매개변수로 변환되어 매번 임시 string이 만들어집니다. 호출 빈도가 높은 콜백이라면 std::string으로 미리 만들어 바인딩하는 편이 낫습니다. 또 std::function에 담는 순간 타입 소거가 일어나 호출이 간접 호출로 바뀌고, 저장할 객체가 크면 힙 할당이 발생할 수 있습니다. 대부분의 콜백 시스템에서는 무시해도 되는 비용이지만, 초당 수백만 번 불리는 경로라면 템플릿 매개변수로 호출 객체를 받는 설계가 더 빠릅니다.
bind vs 람다
// bind
auto f1 = bind(add, 5, _1);
// 람다 (더 명확)
auto f2 = [](int x) { return add(5, x); };
int main() {
cout << f1(10) << endl; // 15
cout << f2(10) << endl; // 15
}
람다 장점:
- 더 읽기 쉬움
- 타입 추론
- 컴파일 에러 명확
bind 장점:
- 인자 재배치를 짧게 표현할 수 있음
- 인자 타입을 적지 않아도 됨 (C++11 람다는
auto매개변수가 없음)
C++11만 쓸 수 있던 시절에는 bind가 나은 경우가 두 가지 있었습니다. 첫째는 이동 전용 객체(std::unique_ptr)를 캡처하는 경우로, C++11 람다는 이동 캡처를 지원하지 않아 bind로 우회했습니다. C++14의 초기화 캡처([p = std::move(ptr)])로 이 이유는 사라졌습니다. 둘째는 제네릭 호출로, bind 객체의 operator()는 템플릿이라 어떤 타입의 인자든 받지만 C++11 람다는 매개변수 타입을 고정해야 했습니다. 이것도 C++14의 auto 매개변수로 해결되었습니다.
“Effective Modern C++” Item 34가 람다를 권하는 근거 중 하나는 인자 평가 시점입니다. bind(setAlarm, steady_clock::now() + 1h, _1)처럼 쓰면 now()는 bind 객체를 만들 때 한 번 평가되지만, 람다 본문에 쓰면 호출할 때 평가됩니다. 의도가 “호출 시점부터 1시간 뒤”라면 bind 쪽이 조용히 틀린 결과를 냅니다. C++20의 std::bind_front(C++23에는 std::bind_back도 추가)는 placeholder 없이 앞쪽 인자만 고정하므로, 부분 적용이 목적이라면 std::bind보다 이쪽이 실수의 여지가 적습니다.
자주 발생하는 문제
문제 1: 참조 바인딩
int x = 10;
// ❌ 복사
auto f1 = bind(add, x, _1);
x = 20;
cout << f1(5) << endl; // 15 (x=10 복사됨)
// ✅ 참조
auto f2 = bind(add, ref(x), _1);
x = 20;
cout << f2(5) << endl; // 25 (x=20 참조)
이 문제는 함수 매개변수가 참조일 때 더 헷갈립니다. void increment(int& n)을 bind(increment, x)로 묶어 호출하면 컴파일은 되지만, 증가하는 것은 bind 객체 안에 저장된 복사본이지 원래 x가 아닙니다. 기존 코드를 고치다가 “카운터가 전혀 안 올라간다”는 버그를 만나면 가장 먼저 의심할 곳이 이런 bind 호출입니다. std::ref를 쓰면 참조가 유지되지만 이번에는 원본 변수의 수명을 신경 써야 하므로, 지역 변수를 ref로 묶은 bind 객체를 스레드나 비동기 작업에 넘기면 댕글링 참조가 됩니다.
문제 2: placeholder 순서
// ❌ 헷갈림
auto f = bind(subtract, _2, _1); // 순서 바뀜
cout << f(10, 3) << endl; // -7 (3 - 10)
// ✅ 람다 (명확)
auto f2 = [](int a, int b) { return subtract(b, a); };
cout << f2(10, 3) << endl; // -7
문제 3: 중첩 bind
// ❌ 복잡
auto f = bind(add, bind(multiply, _1, 2), _2);
// ✅ 람다 (명확)
auto f2 = [](int x, int y) { return add(multiply(x, 2), y); };
중첩된 bind는 안쪽 bind를 “지금 호출하라”는 뜻인지 “함수 객체 그대로 넘기라”는 뜻인지 구분하기 어렵습니다. 표준 규칙상 bind 표현식이 인자로 들어가면 항상 먼저 평가되므로, 정말 함수 객체 자체를 넘기고 싶을 때는 std::function으로 감싸 bind 표현식이 아니게 만들어야 합니다. 이런 규칙을 알아야만 읽을 수 있는 코드라면 람다로 바꾸는 것이 유지보수 측면에서 확실히 낫습니다. bind 관련 컴파일 에러는 수십 줄짜리 템플릿 인스턴스화 메시지로 나오는 경우가 많아, 원인을 찾는 데 시간이 오래 걸린다는 점도 이유입니다.
실무 패턴
패턴 1: 비교 함수 커스터마이징
struct Person {
std::string name;
int age;
};
bool compareByAge(const Person& a, const Person& b) {
return a.age < b.age;
}
bool compareByName(const Person& a, const Person& b) {
return a.name < b.name;
}
// 사용
std::vector<Person> people = {
{"Alice", 30},
{"Bob", 25},
{"Charlie", 35}
};
// 나이순 정렬
std::sort(people.begin(), people.end(), compareByAge);
// 이름순 정렬
std::sort(people.begin(), people.end(), compareByName);
이 패턴은 bind를 쓰지 않아도 되는 경우의 예입니다. 비교 함수가 이미 인자 두 개를 받는 형태라서 함수 이름을 그대로 넘기면 됩니다. 다만 함수 포인터로 넘기면 컴파일러가 인라인하기 어려운 경우가 있어, 성능이 중요한 정렬에서는 [](const Person& a, const Person& b) { return a.age < b.age; }처럼 람다를 직접 넘기는 쪽이 보통 더 빠릅니다. 람다는 고유한 타입을 가지므로 std::sort가 그 타입으로 인스턴스화되어 비교 코드가 정렬 루프 안에 바로 들어갑니다.
패턴 2: 스레드 콜백
class Worker {
public:
void process(int id, const std::string& task) {
std::cout << "Worker " << id << ": " << task << '\n';
}
};
// 사용
Worker worker;
// 멤버 함수를 스레드에 전달
std::thread t1(std::bind(&Worker::process, &worker, 1, "Task A"));
std::thread t2(std::bind(&Worker::process, &worker, 2, "Task B"));
t1.join();
t2.join();
사실 std::thread 생성자는 내부적으로 bind와 같은 방식으로 인자를 저장하므로 std::thread t1(&Worker::process, &worker, 1, "Task A");처럼 bind 없이 바로 넘겨도 똑같이 동작합니다. 인자를 복사해 저장한다는 규칙도 같아서, 스레드 함수가 참조 매개변수를 받는다면 std::ref로 감싸야 합니다. 감싸지 않으면 std::thread는 bind와 달리 컴파일 에러를 내는데, 복사된 임시값을 비 const 참조에 묶을 수 없기 때문입니다. 두 스레드가 같은 worker를 공유하므로 process가 멤버 변수를 수정한다면 뮤텍스가 필요하다는 점도 잊지 말아야 합니다.
패턴 3: 함수 어댑터
int divide(int a, int b) {
return a / b;
}
// 두 번째 인자(제수) 고정
auto divideBy = [](int divisor) {
return std::bind(divide, std::placeholders::_1, divisor);
};
// 사용
auto divideBy2 = divideBy(2);
auto divideBy10 = divideBy(10);
std::cout << divideBy2(100) << '\n'; // 50
std::cout << divideBy10(100) << '\n'; // 10
람다가 bind 객체를 반환하는 “함수를 만드는 함수” 형태입니다. divisor는 bind 객체에 값으로 복사되므로 divideBy가 반환된 뒤에도 안전하게 쓸 수 있습니다. 같은 일을 람다만으로 쓰면 [](int d) { return [d](int a) { return divide(a, d); }; }가 됩니다. 어느 쪽이든 divideBy(0)을 막는 검사가 없으면 호출 시점에 0으로 나누기가 일어나므로, 고정하는 값의 유효성은 어댑터를 만드는 쪽에서 확인하는 것이 좋습니다.
FAQ
Q1: bind는 언제 사용하나요?
A:
- 부분 적용: 일부 인자를 미리 고정
- 멤버 함수 바인딩: 멤버 함수를 일반 함수처럼 사용
- 인자 재배치: 인자 순서 변경
// 부분 적용
auto add5 = std::bind(add, 5, std::placeholders::_1);
// 멤버 함수
auto f = std::bind(&Calculator::multiply, &calc, std::placeholders::_1, std::placeholders::_2);
Q2: bind vs 람다?
A: 대부분 람다가 더 명확합니다. bind는 특수한 경우만 사용합니다.
// bind: 복잡
auto f1 = std::bind(add, 5, std::placeholders::_1);
// 람다: 명확 (권장)
auto f2 = [](int x) { return add(5, x); };
람다 권장 이유:
- 더 읽기 쉬움
- 타입 추론이 명확
- 컴파일 에러가 명확
Q3: 성능 오버헤드는?
A: 최적화가 잘 되면 작지만, 람다보다 불리한 경우가 있습니다.
auto f = std::bind(add, 5, std::placeholders::_1);
f(10); // add를 함수 포인터로 저장 → 인라인 여부는 컴파일러 재량
bind(add, ...)에서 add는 함수 포인터로 저장되기 때문에, 컴파일러가 호출 대상을 추적하지 못하면 간접 호출이 남습니다. 람다는 본문에서 add를 직접 호출하므로 인라인되기가 더 쉽습니다. 최신 GCC/Clang은 -O2에서 두 경우 모두 같은 코드로 만드는 경우가 많지만 보장되지는 않습니다. 여기에 std::function으로 감싸면 타입 소거에 따른 간접 호출이 추가되므로, 성능 차이가 궁금하다면 실제 빌드 설정으로 측정하는 것이 정확합니다.
Q4: 참조 바인딩은 어떻게 하나요?
A: std::ref() 또는 std::cref() 를 사용합니다.
int x = 10;
// ❌ 복사
auto f1 = std::bind(add, x, std::placeholders::_1);
x = 20;
f1(5); // 15 (x=10 복사됨)
// ✅ 참조
auto f2 = std::bind(add, std::ref(x), std::placeholders::_1);
x = 20;
f2(5); // 25 (x=20 참조)
Q5: bind는 deprecated인가요?
A: 아니지만, 새 코드에서는 람다나 C++20 std::bind_front를 더 권장합니다. deprecated되어 제거된 것은 C++98의 bind1st/bind2nd이고, std::bind 자체는 C++23에서도 표준에 남아 있습니다.
// bind: 여전히 유효하지만...
auto f1 = std::bind(add, 5, std::placeholders::_1);
// 람다: 더 권장
auto f2 = [](int x) { return add(5, x); };
Q6: placeholder는 무엇인가요?
A: 호출 시 전달될 인자의 위치를 나타냅니다.
using namespace std::placeholders;
// _1: 첫 번째 인자
auto f1 = std::bind(add, 5, _1);
f1(10); // add(5, 10)
// _2: 두 번째 인자
auto f2 = std::bind(subtract, _2, _1);
f2(10, 3); // subtract(3, 10)
Q7: 중첩 bind는 가능한가요?
A: 가능하지만 복잡합니다. 람다를 권장합니다.
// ❌ 중첩 bind: 복잡
auto f = std::bind(add, std::bind(multiply, _1, 2), _2);
// ✅ 람다: 명확
auto f2 = [](int x, int y) { return add(multiply(x, 2), y); };
Q8: bind 학습 리소스는?
A:
- cppreference.com - std::bind
- “Effective Modern C++” by Scott Meyers (Item 34)
- “The C++ Standard Library” by Nicolai Josuttis
관련 글: lambda, function, placeholders.
std::bind는 함수와 인자를 미리 바인딩하여 새로운 함수 객체를 생성하는 C++11 함수입니다.
같이 보면 좋은 글
- C++ initializer_list 생성자: vector{10, 20}과 (10, 20)이 다른 이유와 중괄호 초기화 우선순위
- C++ 전달 참조(유니버설 레퍼런스): T&&가 lvalue도 받는 조건과 std::forward
- C++ auto와 decltype: 타입 추론 규칙, decltype(auto), AAA 스타일
- C++ auto 타입 추론 | 복잡한 타입을 컴파일러에 맡기기