노드 예제에 이런 줄이 설명 없이 지나가요.
std::bind(&JointEcho::cb, this, std::placeholders::_1)처음 보면 암호문이에요. &가 왜 붙고, this를 왜 또 넘기고, _1은 뭔지 모르겠죠. 이 글은 그 한 줄을 풉니다.
한 문장으로 미리 말하면, C++에서도 함수를 값처럼 변수에 담고 인자로 넘길 수 있어요. 델리게이트가 하던 일이고 문법 표면만 달라요.
함수를 받는 함수
매개변수 타입에 std::function을 써요.
// double 하나를 받고 아무것도 안 돌려주는 함수를 받겠다는 뜻
void forEachAngle(const std::vector<double>& q, std::function<void(double)> action) {
for (double a : q) action(a); // 받은 함수를 부른다
}읽는 법은 반환형(인자형) 이에요. void(double)은 double 하나 받고 반환 없음이죠.
| 다른 언어 | C++ |
|---|---|
Action | std::function<void()> |
Action<double> | std::function<void(double)> |
Func<int, string> | std::function<std::string(int)> |
넘길 수 있는 건 셋이에요. 람다, 일반 함수, 멤버 함수.
대괄호가 하는 일
람다는 가장 많이 쓰고 가장 쉬워요. 다른 언어의 화살표 문법과 같은 자리인데, 앞에 붙는 대괄호 하나가 달라요.
double limit = 1.5;
int over = 0;
forEachAngle(q, [&](double a) { // 바깥 변수를 '참조'로 끌어옴
if (a > limit) over++; // 그래서 바깥의 over가 실제로 증가한다
});이 대괄호가 캡처 목록이고, 람다 바깥의 변수를 안으로 끌어올지를 정해요. 자동으로 해 주는 언어에서 오면 신경 쓸 일이 없던 부분인데 C++은 손으로 정해야 해요.
| 캡처 | 뜻 | 바깥 변수를 고칠 수 있나 |
|---|---|---|
[] | 아무것도 안 끌어옴 | 접근 불가 |
[&] | 전부 참조로 | 고칠 수 있음 |
[=] | 전부 복사해서 | 사본만 바뀜 |
콜백이 바로 실행되고 끝나는 경우엔 참조 캡처가 안전하고 복사도 없어요. 문제는 나중에 실행될 때인데, 그 이야기는 뒤에서 봅니다.
멤버 함수는 왜 이름만으로 안 되나
일반 함수는 이름만 넘기면 돼요. 그런데 클래스 안의 함수는 안 됩니다.
struct JointEcho {
int count = 0;
void onAngle(double a) { count++; }
};
JointEcho node;
forEachAngle(q, node.onAngle); // ✗ 컴파일 에러왜 안 될까요. count++를 하려면 어느 객체의 count인가를 알아야 하거든요. 즉 멤버 함수는 항상 숨은 인자 하나를 더 받아요. 그래서 함수 이름만으로는 정보가 모자라요.
객체와 함수를 한 덩어리로 묶어 주는 언어에서 오면 이 부분이 낯설어요. C++은 그 묶는 일을 직접 해야 하고, 방법이 둘이에요.
람다로 감싸기가 요즘 방식이에요.
forEachAngle(q, [&node](double a) { node.onAngle(a); });객체를 캡처하고 그 객체의 함수를 부르는 람다를 만든 거예요. 읽기 쉽고 추가 문법이 없어요.
std::bind 는 기존 코드에 많이 남아 있어요.
forEachAngle(q, std::bind(&JointEcho::onAngle, &node, _1));드디어 처음 그 줄과 같은 모양이 나왔어요. 조각별로 뜯으면 이래요.
| 조각 | 뜻 |
|---|---|
&JointEcho::onAngle | 그 클래스의 함수 자체를 가리킴 |
&node | 어느 객체에 대해 부를지 (숨은 인자 자리를 채움) |
_1 | 나중에 호출할 때 받는 첫 인자를 여기 넣으라는 표시 |
_1이 자리표시자예요. 미리 채워 넣을 인자와 나중에 받을 인자를 섞을 수 있는데, 아직 모르는 인자는 자리만 잡아 두는 거예요.
처음의 코드와 대조하면 완전히 같아요.
std::bind(&JointEcho::cb, this, std::placeholders::_1)
// └ 클래스의 cb └ 이 객체 └ 메시지가 들어올 자리클래스 안에서 쓰기 때문에 객체 자리에 this가 온 것뿐이에요.
새로 짤 땐 람다가 나아요. 읽기 쉽고 오타가 덜 나거든요. 다만 공식 예제와 기존 코드에 std::bind가 널려 있어서 읽을 줄은 알아야 해요.
500ms는 문자열이 아니에요
타이머 예제 첫 줄에 이게 나와요.
timer_ = this->create_wall_timer(500ms, std::bind(&MyNode::tick, this));숫자 뒤에 단위를 붙이는 문법이 있어요. 쓰려면 선언 한 줄이 필요해요.
using namespace std::chrono_literals; // 이게 있어야 500ms를 쓸 수 있다
auto period = 500ms;
auto total = 500ms + 2s; // 2500ms — 단위 변환이 자동
long long n = total.count(); // 숫자만 꺼내기왜 그냥 숫자로 안 쓸까요. 숫자만 있으면 밀리초인지 초인지 알 수 없어서예요. 지연 함수를 쓸 때 “이게 ms였나 s였나” 헷갈렸던 그 문제를 타입으로 막은 거예요.
정수 나눗셈 함정도 있어요. 1초를 3으로 나누면 333ms가 아니라 나노초 단위로 딱 떨어지게 계산돼요. 단위가 섞이면 항상 더 정밀한 쪽으로 맞춰져요.
캡처한 것의 수명은 내가 책임져요
여기가 가비지 컬렉터가 있는 언어에서 오면 존재 자체를 모르는 함정이에요. 델리게이트가 객체를 잡고 있으면 지워지지 않는 언어와 달리, C++에서 람다가 잡은 건 그냥 주소이고 원본이 죽어도 람다는 그걸 몰라요.
std::function<void()> makeCallback() {
int localCount = 0;
return [&]() { localCount++; }; // ✗ 함수가 끝나면 사라지는 변수
} // 반환된 람다는 죽은 주소를 들고 있다| 시점 | 변수 | 람다가 가진 것 |
|---|---|---|
| 함수 안 | 스택에 존재, 값 0 | 그 변수의 주소 |
| 반환 직후 | 소멸됨 | 같은 주소 (이제 남의 땅) |
| 나중에 실행 | 없음 | 죽은 주소에 증가 연산 |
크래시하면 다행이고 대개는 조용히 엉뚱한 메모리를 건드려요. 콜백을 나중에 실행할 거라면 참조 캡처를 쓰면 안 돼요.
노드 코드에서 this 캡처가 널리 쓰이는 건 노드 객체가 공유 포인터로 관리되어 실행기가 도는 동안 살아 있다고 보장되기 때문이에요. 반대로 노드를 지역 변수로 만들어 놓고 콜백을 남기면 위와 똑같은 사고가 나요.
수명을 타입으로 적는 도구들은 C++ 소유권과 수명 — 스마트 포인터에 정리했어요. 참조 카운트를 찍어 보는 함수가 수명 문제를 디버깅할 때 첫 번째로 보는 값이에요.
처음 그 줄, 다시 읽기
이제 암호문이 이렇게 읽혀요.
sub_ = this->create_subscription<sensor_msgs::msg::JointState>(
"joint_states", 10,
std::bind(&JointEcho::cb, this, std::placeholders::_1));메시지 타입을 템플릿 인자로 넘기고, 토픽 이름과 큐 크기를 주고, “메시지가 오면 이 객체의 그 함수를 불러라”를 한 덩어리로 묶은 거예요.
같은 걸 람다로 쓰면 이렇게 됩니다.
sub_ = this->create_subscription<sensor_msgs::msg::JointState>(
"joint_states", 10,
[this](const sensor_msgs::msg::JointState& msg) { cb(msg); });C++에서 함수를 넘기는 건 델리게이트와 하는 일이 같고 표면만 달라요. 새로 배울 건 사실상 둘뿐이에요. 멤버 함수는 객체와 함께 묶어야 넘어간다는 것, 그리고 캡처한 것의 수명은 내가 책임진다는 것. 앞의 것은 문법이라 하루면 익고, 뒤의 것은 습관이라 계속 물려요.
나중에 실행될 콜백에서 참조 캡처를 봤을 때 한 번 멈칫하게 되면 그걸로 충분해요.