CodeGym /행동 /C++ SELF /std::function — 언제 필요한가와 대가

std::function — 언제 필요한가와 대가

C++ SELF
레벨 22, 레슨 3
사용 가능

1. 왜 std::function가 필요한가

std::function을 접하게 되는 이유는 보통 '미적으로 좋아서'가 아니라 컴파일러가 또다시 말하기 때문입니다: «아니요, 이렇게는 안 됩니다». 그리고 이건 정상입니다: 컴파일러는 악역이 아니라, 여러분이 정확히 어떤 타입을 의도했는지 추측하고 싶어하지 않을 뿐입니다.

상황을 상상해보세요: 서로 다른 두 처리 규칙을 작성했습니다. 이들은 같은 파라미터를 받고 같은 타입을 반환하지만 구현 방식은 다릅니다(예: 하나는 람다, 다른 하나는 일반 함수). 사람 관점에서는 '같은 인터페이스'지만 C++ 관점에서는 서로 다른 엔티티입니다.

핵심 문제는 다음과 같습니다: 각 람다는 자체적인 타입을 가진다, 두 람다가 의미상 같아 보여도 말입니다. 따라서 변수에 auto를 쓰면 그 변수는 정확히 하나의 람다 타입만 담을 수 있고, 다른 람다로 '교체'하는 것은 일반적으로 불가능합니다.

작은 '고통' 데모:

#include <iostream>

int main() {
    auto f = [](int x) { return x + 1; };

    // f = [](int x) { return x * 2; }; // <- 컴파일 오류: 다른 람다 타입

    std::cout << f(10) << '\n'; // 11
}

왜 그런가요? 왜냐하면 auto f = ...는 특정 람다의 타입(그의 closure type)을 유추했고, 이후에 f는 반드시 그 타입을 유지해야 하기 때문입니다.

무엇이 std::function인가, 어떤 문제를 푸는가

'호출 가능한 것(callable)을 저장하고 싶다'고 할 때, 실제로 우리는 동작을 담는 컨테이너를 원합니다: '이게 시그니처다 — 구현은 달라도 된다'. 바로 그것이 std::function이 해결하는 문제입니다.

std::function<R(Args...)>는 내부에 동일한 시그니처를 가진 다양한 호출 가능 엔티티(callable)를 담을 수 있는 래퍼 객체입니다: 일반 함수, 캡처를 가진 람다, 다른 펑크토어 등. 내부에서 구체 타입을 숨기고 통일된 operator() 인터페이스를 제공합니다.

지금 단계에서 가장 중요한 점은: std::function은 동일한 시그니처를 가진 다양한 callable을 하나의 변수 타입으로 다루고 싶을 때 필요하다는 것입니다.

기본 문법: 선언, 할당, 호출

std::function의 문법은 처음에는 다소 '관료적으로' 보일 수 있지만, 상당히 정직합니다: 호출 계약을 명시적으로 적어줍니다.

대부분의 경우 필요한 것:

  • #include <functional>
  • 시그니처 표기: std::function<반환(파라미터...)>

간단한 예: 'int을 받고 int을 반환하는 무언가'를 저장해보겠습니다:

#include <functional>
#include <iostream>

int main() {
    std::function<int(int)> f = [](int x) { return x + 1; };

    std::cout << f(10) << '\n'; // 11

    f = [](int x) { return x * 2; };
    std::cout << f(10) << '\n'; // 20
}

이것이 바로 실용적인 '마법'입니다: 변수 f는 하나이고, 타입도 하나(std::function<int(int)>)이며, 구현은 원할 때마다 바꿀 수 있습니다.

std::function는 비어 있을 수 있다

std::function에는 '빈 상태'가 있습니다: 아무 함수도 담고 있지 않을 수 있습니다. 콜백이 선택적일 때 편리하지만, 체크를 잊으면 위험합니다.

#include <functional>
#include <iostream>

int main() {
    std::function<void()> action; // 비어 있음

    if (!action) {
        std::cout << "action is empty\n"; // action is empty
    }

    action = [] { std::cout << "run\n"; };

    if (action) {
        action(); // run
    }
}

비어 있는 std::function를 호출하면 런타임 에러가 발생합니다(보통 std::bad_function_call 예외). 아직 try/catch를 다루지 않았으니, 기본 방어는 빈 상태 검사입니다: if (f) { f(...); }.

2. 언제 사용하고, 비용은 어느 정도인가

std::function이 실제로 필요한 경우

std::function에 '빠지기' 쉽고, 모든 곳에 '혹시 몰라서' 쓰기 시작할 수 있습니다. 이는 유연성을 더하지만 때로는 불필요한 복잡성과 오버헤드를 불러옵니다. 그래서 전형적인 경우를 나누어 생각하는 것이 유용합니다.

런타임에 동작을 선택할 때

예를 들어 설정이 있어서: '길이로 정렬' 혹은 '사전식 정렬'을 선택한다고 합시다. 선택된 규칙을 변수에 저장해두고 나중에 적용하고 싶을 수 있습니다.

auto로는 '다른 람다 타입' 문제에 부딪힙니다. std::function을 사용하면 문제 없습니다:

#include <functional>
#include <iostream>
#include <string>

int main() {
    bool by_length = true;

    std::function<bool(const std::string&, const std::string&)> cmp;

    if (by_length) {
        cmp = [](const std::string& a, const std::string& b) { return a.size() < b.size(); };
    } else {
        cmp = [](const std::string& a, const std::string& b) { return a < b; };
    }

    std::cout << cmp("cat", "horse") << '\n'; // 1 (true)
}

callable을 함수에서 반환할 때 편리함

때때로 '파라미터로 규칙/필터를 만들어 반환'하는 공장 함수를 쓰고 싶을 때가 있습니다. 람다를 직접 반환할 수는 있지만, 시그니처에 람다 타입을 적을 수는 없습니다. std::function은 반환 타입을 명시적으로 만들어 API에 편리합니다.

#include <functional>

std::function<bool(int)> make_is_big(int limit) {
    return [limit](int x) { return x >= limit; };
}

요점: 내부에 limit을 보관하는 '검사 함수'를 반환합니다.

언제 std::function이 과도한가

예를 들어 다음과 같이 작성한다면:

std::sort(v.begin(), v.end(), [](int a, int b){ return a < b; });

이 경우에는 std::function으로 컴퍼레이터를 감쌀 필요가 없다. 알고리즘은 람다를 직접 받으며, 컴파일러는 이러한 호출을 더 잘 최적화합니다.

실용적 규칙: callable이 '한 줄 내에서' 존재하고 후에 저장할 필요가 없다면, std::function은 보통 필요하지 않습니다.

std::function의 비용

std::function은 '공짜 다형성'이 아닙니다. 문제를 해결하지만 대가를 치릅니다.

비용 1: 숨겨진 래퍼와 간접 호출

람다가 auto로 보관될 때 컴파일러는 정확한 타입을 알고 있고 호출을 인라인화하거나 더 공격적으로 최적화할 수 있습니다.

하지만 callable이 std::function 안에 숨겨지면 호출은 일반적으로 추가적인 간접 수준을 통해 이뤄집니다(즉, '래퍼 안에 있는 것을 호출').

비용 2: 경우에 따라 메모리 할당

어떤 callable은 작아서(예: 캡처 없는 람다) std::function 내부에 동적 할당 없이 저장될 수 있습니다. 그러나 캡처로 큰 객체를 잡아두면 std::function이 메모리를 할당할 수 있습니다.

이 단계에서는 '정확한 규칙'을 외우는 것보다 핵심 아이디어를 이해하는 것이 중요합니다: std::function은 보통 생각보다 무거울 수 있다.

비용 3: 복사가 비용이 될 수 있음

std::function은 복사 가능하지만, 복사는 내부에 담긴 것을 복사하는 것을 의미합니다. 따라서 '자주 들고 다니는 작은 것' 역할을 기대하면 의외로 무거워질 수 있습니다.

간단 비교표: auto vs 함수 포인터 vs std::function

저장하는 것 타입 예시 변수를 캡처할 수 있나? 하나의 변수에 다양한 구현을 저장할 수 있나? 코멘트
특정 타입 람다
auto f = []{...};
예 아니요 가장 가벼운 옵션이지만 타입이 고정됨
함수 포인터
int (*pf)(int)
아니요 예, 단 캡처 없는 함수/람다만 단순하고 빠르지만 캡처 불가
std::function
std::function<int(int)>
예 예 세 가지 중 가장 유연하지만 비용이 큼

3. 실용 예제: 미니 커맨드 디스패처

이제 std::function의 의미를 잘 보여주는 실용적인 것을 만들어봅니다: 각 커맨드가 이름과 핸들러를 가지는 커맨드 테이블을 구현합니다. 핸들러들은 서로 다를 수 있지만(예: 캡처를 가진 람다), 하나의 컨테이너에 저장하고 싶습니다.

간단한 작업 모델이 있다고 가정합시다:

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

이제 '커맨드'를 이름 + 핸들러 쌍으로 정의합니다. 핸들러는 작업 목록과 커맨드 인자(예: 커맨드 뒤의 문자열)를 받는다고 합시다:

#include <functional>
#include <string>
#include <string_view>
#include <vector>

struct Command {
    std::string name;
    std::function<void(std::vector<Task>&, std::string_view)> run;
};

여기서 주목할 점: 바로 이곳에서 std::function이 편리합니다. 모든 커맨드의 run이 동일한 타입을 갖도록 하고, 구현은 달라도 되게 합니다.

가장 단순한 커맨드: list

출력 함수(간단하게)를 만들어봅시다:

#include <iostream>
#include <vector>

void print_tasks(const std::vector<Task>& tasks) {
    for (const auto& t : tasks) {
        std::cout << (t.done ? "[x] " : "[ ] ") << t.id << ": " << t.title << '\n';
    }
}

이제 캡처 없는 람다로 list 커맨드를 만들어봅니다:

Command make_list_command() {
    return Command{
        "list",
        [](std::vector<Task>& tasks, std::string_view) {
            print_tasks(tasks);
        }
    };
}

캡처가 있는 커맨드: 'add'와 '다음 id'

작업을 추가하려면 카운터 next_id가 필요합니다. 외부에서 보관하고 참조로 캡처할 수 있습니다. 이는 좋은 예이지만 수명 규칙을 항상 염두에 두어야 합니다: next_id는 커맨드보다 오래 살아야 합니다.

Command make_add_command(int& next_id) {
    return Command{
        "add",
        [&next_id](std::vector<Task>& tasks, std::string_view arg) {
            tasks.push_back(Task{next_id, std::string(arg), false});
            ++next_id;
        }
    };
}

커맨드 검색과 실행

커맨드들을 벡터에 모아서 std::find_if로 이름으로 찾습니다.

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<Task> tasks;
    int next_id = 1;

    std::vector<Command> cmds;
    cmds.push_back(make_list_command());
    cmds.push_back(make_add_command(next_id));

    std::string cmd = "add";
    std::string arg = "Buy milk";

    auto it = std::find_if(cmds.begin(), cmds.end(),
                           [&](const Command& c) { return c.name == cmd; });

    if (it != cmds.end() && it->run) {
        it->run(tasks, arg);
    }

    print_tasks(tasks); // [ ] 1: Buy milk
}

여기서 중요한 점: it->run은 std::function이며, 우리는 그것이 비어 있지 않은지 검사합니다. 이 예제에서는 비어 있지 않지만, 그런 습관은 유용합니다.

4. std::function 사용 시 흔한 실수

실수 #1: 'std::function은 만능이니 어디든 쓰자'
범용성은 항상 장점이 아닙니다. callable을 '나중에 저장'할 필요가 없고 한 번만 쓰인다면(특히 std::sort/std::find_if/std::transform 내부에서) std::function은 종종 불필요한 오버헤드와 가독성 저하를 가져옵니다. 그런 곳에서는 람다를 바로 쓰는 편이 보통 더 명확합니다.

실수 #2: 잘못된 시그니처의 std::function
흔한 상황: std::function<void(int)>을 선언했는데, 나중에 const 참조를 받거나 bool을 반환하는 람다를 넣으려 합니다. 형식적으로는 다른 계약입니다. 실용적인 습관: 먼저 말로 '무엇을 받고 무엇을 반환하는지'를 정하고, 그 다음에 R(Args...)를 적으세요.

실수 #3: 비어 있는 std::function 호출
std::function은 비어 있을 수 있으며, 비어 있는 것을 호출하면 런타임 에러(보통 예외)가 발생합니다. 아직 try/catch를 배우지 않았다면, 기본 방어는 if (f) { f(...); } 검사입니다.

실수 #4: 수명이 보장되지 않는 곳에서 참조 캡처
로컬 변수를 참조로 캡처한 커맨드를 컨테이너에 넣고 나중에 사용하면 위험합니다. 만약 그 변수가 이미 '사라졌다면', 동작은 정의되지 않습니다. 이전의 make_add_command(int& next_id) 예제에서는 next_id가 커맨드보다 오래 살아야 한다는 점을 기억하세요. 확신이 없으면 값으로 캡처하거나 상태 저장 아키텍처를 재설계하세요.

실수 #5: 내부에 무거운 캡처를 넣고 비용을 고려하지 않음
람다에서 큰 std::string이나 전체 std::vector를 값으로 캡처하면, 그 캡처는 std::function 안에 들어갑니다. 그런 경우 복사/이동 비용이 커지고, 때로는 메모리 할당도 발생합니다. 값으로 캡처할 때는 그것이 정말로 '설정으로 고정할 값'인지, 그리고 작게 유지할 수 있는지 고려하세요.

1
과제
C++ SELF, 레벨 22, 레슨 3
잠금
트릭 선택
트릭 선택
1
과제
C++ SELF, 레벨 22, 레슨 3
잠금
선택적 핸들러
선택적 핸들러
1
과제
C++ SELF, 레벨 22, 레슨 3
잠금
변환 팩토리
변환 팩토리
1
과제
C++ SELF, 레벨 22, 레슨 3
잠금
명령 디스패처
명령 디스패처
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION