C++ · 심층 가이드
C++ 함수 완전 정리
C++ 함수를 값처럼 다루는 20개 예제로 오버로드 해석과 기본 인자, 가변 인자 템플릿, 람다 캡처, 그리고 함수를 돌려주는 함수까지 실제 코드로 확인합니다.
C++에서 이름 하나에는 여러 실체가 붙습니다. 오버로드 해석과 템플릿 인자 추론이 호출 지점에서 동시에 돌아가기 때문에, 다른 언어에서 넘어온 사람은 대개 "왜 하필 저 오버로드가 선택되는가"에서 먼저 막힙니다. 여기에 더해 매개변수를 값으로 받는지 const&로 받는지가 복사 비용과 수명까지 결정합니다. 그래서 C++에서 함수를 익히는 일은 사실상 값의 수명과 소유권을 익히는 일과 겹칩니다.
앞쪽 기본 함수 선언과 기본값 매개변수는 시그니처를 읽는 감각을 만드는 구간입니다. 가변 인자 합계부터는 파라미터 팩과 폴드 표현식이 등장하면서 컴파일 타임에 코드가 펼쳐진다는 감각이 붙습니다. 뒤쪽의 람다 함수, 클로저 카운터, 함수 팩토리, compose 함수는 한 줄기로 이어집니다. 캡처가 상태를 들고, 그 상태를 가진 호출 가능 객체를 반환하고, 다시 그것들을 합성하는 순서로 읽으면 좋습니다.
가장 자주 터지는 곳은 캡처입니다. [&]로 지역 변수를 참조 캡처한 람다를 함수 밖으로 돌려주면 그 참조는 이미 사라진 스택을 가리킵니다. 카운터처럼 상태를 들고 다녀야 한다면 값 캡처에 mutable을 붙이거나 캡처 초기화로 소유권을 람다 안에 옮겨야 합니다. 반환 타입도 습관적으로 std::function을 쓰면 타입 소거 때문에 힙 할당과 간접 호출이 따라붙으니, 합성이나 커링처럼 즉시 호출되는 자리에서는 auto로 받아 그대로 넘기는 편이 낫습니다.
01기본 함수 선언
int add(int a, int b)를 파일 상단에 정의하고 main에서 add(1, 2)를 불러 3을 출력합니다. 반복되는 계산에 이름을 붙여 분리하는 가장 기본적인 단계입니다. C++ 컴파일러는 위에서 아래로 이름을 찾기 때문에, 정의를 main 아래로 옮기면 앞쪽에 선언을 따로 두지 않는 한 컴파일이 실패합니다.
C++ code
// 기본 함수 선언
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
std::cout << add(1, 2) << "\n";
}선언만 헤더에 두고 정의를 소스로 옮기면, 본문을 고쳐도 그 헤더를 포함한 파일 전체를 다시 컴파일하지 않아도 됩니다.
선언과 정의의 매개변수 타입이 조금이라도 다르면 별개의 오버로드로 취급되어, 정의하지 않은 함수를 부른 셈이 되고 링크 단계에서야 오류가 드러납니다.
02두 값을 더하는 함수
make_counter는 지역 변수 current를 값으로 캡처한 mutable 람다를 std::function<int()>로 감싸 돌려주고, next() 첫 호출이 1을 출력합니다. 호출할 때마다 이어지는 번호가 필요할 때 쓰는 패턴입니다. 캡처된 값은 클로저 안에 복사돼 함수가 끝난 뒤에도 살아남습니다.
C++ code
// 두 값을 더하는 함수
#include <functional>
#include <iostream>
std::function<int()> make_counter() { int current = 0; return [current]() mutable { return ++current; }; }
int main() {
auto next = make_counter();
std::cout << next() << "\n";
}반환 타입을 auto로 두면 std::function의 타입 소거를 피할 수 있지만, 그 경우 호출한 쪽은 이름을 적을 수 없는 타입을 받게 됩니다.
current를 값이 아니라 참조로 캡처하면 함수가 끝나는 순간 사라진 지역 변수를 가리켜, 호출할 때마다 정의되지 않은 동작이 됩니다.
03기본값 매개변수
기본값 매개변수는 쓰이지 않았고, 두 인자를 모두 명시해 add(3, 2)로 5를 얻습니다. 기본값을 주려면 선언부에 int b = 0처럼 적되 한 번만 적어야 합니다. 여기 인자들은 값으로 복사되어 전달되므로, int 대신 큰 객체를 받는 함수라면 const&로 바꾸는 편이 낫습니다.
C++ code
// 기본값 매개변수
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
std::cout << add(3, 2) << "\n";
}기본 인자는 호출하는 쪽에서 채워 넣으므로, 라이브러리에서 기본값을 바꿔도 다시 컴파일하지 않은 코드에는 옛 값이 그대로 남습니다.
기본값을 가진 매개변수 뒤에 기본값 없는 매개변수를 두면 컴파일 오류가 나고, 오버로드와 섞이면 어느 쪽이 불릴지 모호해집니다.
04가변 인자 합계
가변 인자를 받는 대신, 인자가 전혀 없는 카운터 함수 객체를 만들어 한 번 호출합니다. 개수가 유동적인 합계는 가변 템플릿이나 std::initializer_list로 표현합니다. 여기서 쓰인 std::function은 타입 소거 래퍼라 대상 객체가 크면 힙 할당이 일어날 수 있고, 호출도 간접 분기를 거칩니다.
C++ code
// 가변 인자 합계
#include <functional>
#include <iostream>
std::function<int()> make_counter() { int current = 0; return [current]() mutable { return ++current; }; }
int main() {
auto next = make_counter();
std::cout << next() << "\n";
}가변 템플릿에서는 폴드 표현식으로 인자 전체를 한 줄에 접을 수 있어, 재귀 종료용 오버로드를 따로 만들 필요가 없습니다.
std::initializer_list로 받으면 모든 인자가 같은 타입으로 변환되어야 하므로, 정수와 실수를 섞어 넘기면 축소 변환으로 거부됩니다.
05객체 또는 구조체 반환
구조체를 돌려주는 대신 int 하나를 반환하는 add를 add(5, 2)로 호출해 7을 찍습니다. 여러 값을 함께 반환하려면 구조체나 std::tuple이 필요하며, C++17의 구조적 바인딩으로 받으면 편합니다. 참고로 부호 있는 int의 덧셈이 표현 범위를 넘으면 정의되지 않은 동작입니다.
C++ code
// 객체 또는 구조체 반환
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
std::cout << add(5, 2) << "\n";
}여러 값을 돌려줄 때 필드 이름이 있는 구조체를 쓰면 호출부에서 순서를 헷갈릴 일이 없고, 구조적 바인딩으로 받으면 표기도 짧아집니다.
오버플로가 걱정된다면 결과를 넓은 타입에 담기 전에 피연산자부터 넓혀야 하며, int끼리 더한 뒤 대입하면 이미 넘친 값이 들어갑니다.
06람다 함수
람다는 make_counter 내부에 숨어 있고, 바깥에서는 반환된 함수 객체를 auto next로 받아 호출할 뿐입니다. 함수 안에서 즉석 함수를 만들어 밖으로 내보내는 구조입니다. 이 std::function을 복사하면 캡처된 current까지 함께 복사되어, 두 복사본이 서로 독립적으로 번호를 셉니다.
C++ code
// 람다 함수
#include <functional>
#include <iostream>
std::function<int()> make_counter() { int current = 0; return [current]() mutable { return ++current; }; }
int main() {
auto next = make_counter();
std::cout << next() << "\n";
}람다에 이름을 붙여 auto 변수에 담으면 디버거에서 호출 지점이 드러나고, 같은 식을 여러 번 적는 중복도 사라집니다.
std::function을 복사할 때마다 클로저가 통째로 복사되므로, 무거운 상태를 캡처한 콜백을 값으로 여기저기 넘기면 비용이 조용히 늘어납니다.
07클로저 카운터
제목과 달리 상태를 기억하는 클로저는 없고, 순수한 add에 7과 2를 넘겨 9를 출력합니다. 함수 호출이 끝나면 매개변수는 사라지고 아무것도 남지 않습니다. 이름이 같아도 매개변수 목록이 다르면 오버로드로 공존할 수 있지만, 반환 타입만 다른 두 함수는 정의할 수 없습니다.
C++ code
// 클로저 카운터
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
std::cout << add(7, 2) << "\n";
}오버로드 해석은 인자 타입만 보고 반환값은 보지 않으므로, 결과를 어디에 담느냐로 함수를 골라 주지는 않습니다.
매개변수 이름만 다르고 타입이 같은 오버로드는 재정의로 거부되며, 값 매개변수에 붙인 const도 구분에 쓰이지 않습니다.
08재귀 팩토리얼
재귀 대신 ++current 한 번으로 값을 증가시키는 카운터가 쓰였습니다. 팩토리얼처럼 자기 자신을 부르는 람다는 이름이 없어 곤란한데, std::function 변수를 참조 캡처하거나 C++23의 this auto 매개변수로 해결합니다. 만약 여기서 [¤t]로 캡처했다면 반환 후 사라진 지역 변수를 가리켜 정의되지 않은 동작이 됩니다.
C++ code
// 재귀 팩토리얼
#include <functional>
#include <iostream>
std::function<int()> make_counter() { int current = 0; return [current]() mutable { return ++current; }; }
int main() {
auto next = make_counter();
std::cout << next() << "\n";
}재귀 깊이가 입력에 따라 늘어나는 함수는 스택을 다 쓰면 그대로 죽으므로, 깊이가 커질 수 있다면 반복문이나 명시적 스택으로 바꾸는 편이 안전합니다.
람다를 std::function에 담아 자기 자신을 참조로 캡처하는 방식은 그 변수보다 클로저가 오래 살아남는 순간 매달린 참조가 됩니다.
09고차 함수 map
고차 함수는 없고, 인자 두 개를 더하는 평범한 함수를 add(9, 2)로 호출해 11을 냅니다. 컨테이너 전체에 적용하려면 이 함수를 std::transform에 넘기는 식으로 확장합니다. 다만 이런 정의를 헤더에 그대로 두고 여러 소스에서 포함하면 중복 정의로 링크가 실패하므로 inline이 필요합니다.
C++ code
// 고차 함수 map
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
std::cout << add(9, 2) << "\n";
}함수 이름을 그대로 알고리즘에 넘기면 오버로드 중 어느 것인지 정하지 못해 컴파일이 막히므로, 그럴 때는 람다로 감싸는 편이 간단합니다.
헤더에 inline으로 정의를 두더라도 번역 단위마다 본문이 다르면 정의되지 않은 동작이므로, 조건부 컴파일로 본문을 갈라 쓰면 안 됩니다.
10조건 함수 predicate
여기 등장하는 호출 가능 객체는 참과 거짓을 판정하지 않고 정수를 증가시켜 돌려줍니다. 조건 판별용 함수라면 bool을 반환하도록 만들어 알고리즘에 넘깁니다. 이 람다에 mutable이 없었다면 operator()가 const가 되어 캡처된 current를 수정하는 순간 컴파일 오류가 납니다.
C++ code
// 조건 함수 predicate
#include <functional>
#include <iostream>
std::function<int()> make_counter() { int current = 0; return [current]() mutable { return ++current; }; }
int main() {
auto next = make_counter();
std::cout << next() << "\n";
}술어는 인자를 const&로 받고 상태를 두지 않아야, 알고리즘이 몇 번을 호출하든 같은 결과가 나온다고 믿을 수 있습니다.
mutable 람다를 술어로 넘기면 알고리즘이 사본을 만들어 호출할 수 있어, 누적해 둔 상태가 기대와 다르게 나뉘어 버립니다.
11정렬용 비교 함수
정렬 비교자는 등장하지 않으며, add(11, 2)의 결과 13이 출력될 뿐입니다. 비교 함수는 두 인자를 받아 bool을 돌려주고 엄격 약순서를 지켜야 하는데, 이를 어기면 std::sort가 범위를 벗어나 읽을 수 있습니다. add처럼 짧은 함수는 대개 인라인되지만 inline 키워드 자체는 최적화 지시가 아니라 링크 규칙입니다.
C++ code
// 정렬용 비교 함수
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
std::cout << add(11, 2) << "\n";
}C++20의 삼방 비교 연산자를 정의하면 나머지 관계 연산자들이 자동으로 만들어져 비교 규칙이 서로 어긋날 여지가 줄어듭니다.
비교자가 같은 원소에 참을 돌려주면 엄격 약순서가 깨져 정렬이 범위 밖을 읽는데, 원소 수가 적을 때는 이 오류가 드러나지 않습니다.
12문자열 파서
문자열을 다루는 부분은 없고, 호출할 때마다 증가하는 정수를 내주는 함수 객체를 만들어 씁니다. 파싱에는 std::stoi나 C++17의 std::from_chars가 쓰이며, 후자는 예외 없이 오류 코드로 실패를 알립니다. 이 예제의 반환 타입 std::function<int()>는 인자 없는 호출만 허용합니다.
C++ code
// 문자열 파서
#include <functional>
#include <iostream>
std::function<int()> make_counter() { int current = 0; return [current]() mutable { return ++current; }; }
int main() {
auto next = make_counter();
std::cout << next() << "\n";
}std::from_chars는 어디까지 읽었는지 포인터로 알려 주므로, 남은 문자를 확인해 입력 전체가 소비됐는지 판단할 수 있습니다.
std::function의 시그니처와 넘기는 람다의 인자 개수가 다르면 템플릿 내부까지 파고든 긴 오류가 쏟아져, 원인을 찾기가 유난히 어렵습니다.
13함수 팩토리
함수를 만들어 내는 팩토리 대신, 고정된 add를 add(13, 2)로 호출해 15를 출력합니다. 인자에 따라 다른 동작을 만들어 내려면 람다를 반환하는 함수가 필요합니다. 이 add에 constexpr을 붙이면 상수 인자로 부를 때 컴파일 타임에 계산되어 배열 크기 같은 자리에도 쓸 수 있습니다.
C++ code
// 함수 팩토리
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
std::cout << add(13, 2) << "\n";
}constexpr 함수는 컴파일 시점 계산이 가능할 때만 그렇게 하고, 그 밖에는 평범한 실행 시점 함수로 동작합니다.
constexpr을 붙였다고 항상 컴파일 시점에 계산되지는 않으므로, 반드시 그래야 한다면 결과를 constexpr 변수에 담아 강제해야 합니다.
14메모이제이션
결과를 저장해 재사용하는 캐시는 없고, 매 호출마다 값을 하나씩 올리는 카운터가 있을 뿐입니다. 메모이제이션은 보통 캡처한 std::unordered_map에 인자와 결과를 넣어 구현합니다. 캡처 변수를 담아야 하므로 클로저는 크기를 가진 객체이며, std::function에 넣으면 그 크기만큼 저장 공간이 필요합니다.
C++ code
// 메모이제이션
#include <functional>
#include <iostream>
std::function<int()> make_counter() { int current = 0; return [current]() mutable { return ++current; }; }
int main() {
auto next = make_counter();
std::cout << next() << "\n";
}캐시를 std::unordered_map으로 두면 재해시 뒤에도 원소의 참조와 포인터가 유효하므로, 저장된 결과의 참조를 그대로 돌려줄 수 있습니다.
메모이제이션 캐시에 상한을 두지 않으면 입력 종류만큼 메모리가 늘어나, 오래 도는 프로그램에서는 사실상 누수처럼 보입니다.
15커링
커링 구조는 없고 두 인자를 한 번에 받는 add(15, 2)가 17을 출력합니다. 한 인자씩 받으려면 람다를 반환하는 람다를 겹쳐 써야 합니다. 한 가지 알아 둘 점은 함수 인자들의 평가 순서가 C++17 이후에도 여전히 지정되지 않았다는 것이라, 인자 자리에 부작용이 있는 식을 두면 결과가 달라질 수 있습니다.
C++ code
// 커링
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
std::cout << add(15, 2) << "\n";
}인자 순서를 확실히 하려면 계산을 앞줄에서 지역 변수로 빼내면 되고, 그러면 평가 순서에 기대지 않는 코드가 됩니다.
같은 변수를 인자 두 자리에서 읽고 쓰면 순서가 정해지지 않아 컴파일러나 최적화 수준에 따라 결과가 달라집니다.
16compose 함수
함수 합성이 아니라, 내부 람다를 그대로 감싸 반환하는 make_counter의 결과를 한 번 호출합니다. 두 함수를 잇는 compose는 두 호출 가능 객체를 캡처해 새 람다를 돌려주는 식으로 직접 작성해야 합니다. 반환 타입에 std::function을 쓰려면 <functional> 포함이 반드시 필요합니다.
C++ code
// compose 함수
#include <functional>
#include <iostream>
std::function<int()> make_counter() { int current = 0; return [current]() mutable { return ++current; }; }
int main() {
auto next = make_counter();
std::cout << next() << "\n";
}두 함수를 합성하는 람다는 인자를 auto&&로 받고 std::forward로 넘겨야 참조와 임시값을 그대로 전달할 수 있습니다.
합성한 함수 객체가 원래 함수를 참조로 붙들고 있으면, 그 함수 객체보다 원본이 먼저 사라졌을 때 호출이 무너집니다.
17제네릭 함수
제네릭이 아니라 int 전용으로 고정된 add가 add(17, 2)로 19를 출력합니다. 타입을 열어 두려면 template <typename T>로 바꾸면 됩니다. 다만 템플릿은 사용된 타입마다 별도의 코드가 만들어지므로, 여러 타입으로 호출할수록 실행 파일 크기가 늘어납니다.
C++ code
// 제네릭 함수
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
std::cout << add(17, 2) << "\n";
}제약을 걸고 싶다면 C++20의 개념을 붙여, 맞지 않는 타입에서 템플릿 내부가 아니라 호출 지점에서 오류가 나게 만들 수 있습니다.
템플릿 정의를 소스 파일에만 두면 다른 번역 단위에서 인스턴스화할 수 없어 링크 오류가 나므로, 보통은 헤더에 정의를 둡니다.
18콜백으로 완료 알림
완료를 알리는 콜백을 받는 대신, 함수가 만들어 준 카운터를 직접 호출해 결과를 출력합니다. 콜백을 저장할 자리로는 이 예제와 같은 std::function이 흔히 쓰입니다. 비어 있는 std::function을 호출하면 std::bad_function_call이 던져지므로, 호출 전에 bool 변환으로 확인하는 습관이 필요합니다.
C++ code
// 콜백으로 완료 알림
#include <functional>
#include <iostream>
std::function<int()> make_counter() { int current = 0; return [current]() mutable { return ++current; }; }
int main() {
auto next = make_counter();
std::cout << next() << "\n";
}콜백을 등록하는 쪽과 호출하는 쪽의 수명이 다르다면 대상을 weak_ptr로 잡아 두고, 호출 직전에 살아 있는지 확인하는 방법이 있습니다.
콜백 안에서 자신을 등록한 컨테이너를 수정하면 순회 중인 반복자가 무효가 되므로, 변경 사항은 따로 모아 두었다가 나중에 반영해야 합니다.
19reducer 함수
누적 로직은 없고, 두 값을 더하는 함수 하나가 add(19, 2)로 21을 냅니다. 이 함수는 시그니처가 그대로 std::accumulate의 이항 연산 자리에 맞아떨어져 reducer로 넘길 수 있습니다. 반환값은 복사되지만 int처럼 작은 타입은 메모리를 거치지 않고 레지스터로 전달되는 것이 보통입니다.
C++ code
// reducer 함수
#include <iostream>
int add(int a, int b) { return a + b; }
int main() {
std::cout << add(19, 2) << "\n";
}표준 라이브러리의 투명 함수 객체는 인자 타입을 스스로 추론하므로, 직접 만든 덧셈 함수보다 타입 조합에 유연하게 대응합니다.
int를 돌려주는 이 함수를 실수 원소에 그대로 쓰면 단계마다 결과가 잘려 나가므로, 누산 타입은 원소 타입과 함께 맞춰야 합니다.
20파이프라인 함수
여러 단계를 잇는 파이프라인 대신, 호출 가능한 값을 만들어 돌려주는 함수 하나와 그 호출이 전부입니다. 단계들을 연결하려면 이렇게 반환된 함수 객체들을 컨테이너에 모아 차례로 부르면 됩니다. 이때 std::function의 통일된 타입은 서로 다른 람다를 한 컨테이너에 담을 수 있게 해 주지만 그 대가로 간접 호출이 남습니다.
C++ code
// 파이프라인 함수
#include <functional>
#include <iostream>
std::function<int()> make_counter() { int current = 0; return [current]() mutable { return ++current; }; }
int main() {
auto next = make_counter();
std::cout << next() << "\n";
}여러 단계를 std::function 컨테이너에 담아 순서대로 부르면, 단계 구성을 실행 중에 바꾸거나 조건에 따라 건너뛸 수 있습니다.
각 단계를 std::function으로 감싸면 호출마다 간접 분기가 생겨 인라인이 막히므로, 짧은 함수 수십 개를 잇는 뜨거운 경로에서는 부담이 됩니다.
정리하며
- 매개변수를 값으로 받을지 const 참조로 받을지가 복사 비용과 수명을 함께 결정합니다
- 람다를 반환할 때 참조 캡처는 금지에 가깝고, 값 캡처나 캡처 초기화로 소유권을 옮깁니다
- 가변 인자는 파라미터 팩과 폴드 표현식으로 처리하면 런타임 분기 없이 펼쳐집니다
- 합성과 커링 자리에서는 std::function 대신 auto로 받아 타입 소거 비용을 피합니다
더 깊이 들어가고 싶다면 C++ 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.