C++ · 심층 가이드
C++ 함수형 완전 정리
optional과 variant, ranges를 활용한 15개 예제로 순수 함수, 불변 업데이트, 지연 파이프라인을 C++ 문법 안에서 구성합니다.
함수형 스타일이 C++에 들어온 방식은 조금 특이합니다. 값 의미론이 원래부터 강한 언어라 불변 데이터는 자연스러운데, 대신 값을 복사하는 비용이 눈에 보입니다. 그래서 여기서의 관심사는 "부작용을 없애는가"보다 "불필요한 복사 없이 부작용을 격리하는가"에 가깝습니다. std::optional과 std::variant가 합타입 역할을 맡고, 파이프라인은 ranges의 뷰가 담당하는 식으로, 기존 문법 위에 얹히는 모양입니다.
순수 함수와 불변 업데이트로 값 의미론을 확인한 다음, Maybe 스타일 처리와 Result 스타일 매핑에서 실패를 값으로 다루는 방법으로 넘어가면 흐름이 끊기지 않습니다. compose, partial 적용, curried 함수는 앞의 함수 편과 이어지는 세 항목이라 묶어서 보는 편이 낫고, lazy iterator와 pipeline은 중간 컨테이너를 만들지 않는다는 점에서 성능 편의 스트리밍 주제와도 맞닿아 있습니다.
지연 평가에는 대가가 따릅니다. ranges의 뷰는 원본을 소유하지 않으므로, 임시 컨테이너를 반환하는 함수 결과에 뷰를 붙여 두고 나중에 순회하면 이미 사라진 데이터를 읽게 됩니다. 뷰는 만든 자리에서 소비하거나 결과를 컨테이너로 물질화해 두는 편이 안전합니다. std::optional도 마찬가지로, 값이 없는 상태에서 *나 ->로 접근하면 검사 없이 정의되지 않은 동작이 됩니다. 예외를 원한다면 value(), 기본값을 원한다면 value_or()로 의도를 분명히 적어야 합니다.
01순수 함수
{1, 2, 3, 1}을 담은 벡터를 std::accumulate로 접어 7을 출력합니다. 입력만 받아 값을 돌려주는 계산을 부작용 없이 표현하려 할 때 이런 형태가 나옵니다. 결과 타입은 컨테이너가 아니라 세 번째 인자가 결정하므로, 초기값 0은 곧 int 누산을 뜻하고 실수 합계에는 0.0이 필요합니다.
C++ code
// 순수 함수
#include <iostream>
#include <numeric>
#include <vector>
int main() {
std::vector<int> values = {1, 2, 3, 1};
std::cout << std::accumulate(values.begin(), values.end(), 0) << "\n";
}std::accumulate의 이항 연산 자리에 람다를 넘기면 합계뿐 아니라 최댓값이나 문자열 이어 붙이기 같은 접기도 같은 틀로 표현할 수 있습니다.
이항 연산 안에서 누산기를 참조로 받아 직접 수정하면 반환값과 어긋나 결과가 뒤엉키므로, 항상 새 값을 만들어 돌려주어야 합니다.
02불변 업데이트
가격을 받아 1.1을 곱해 돌려주는 람다를 auto에 담고 20.0으로 호출합니다. 원본을 건드리지 않고 새 값을 만들어 내는 계산 단위를 지역적으로 정의할 때 쓰는 방식입니다. 캡처 목록이 비어 있는 람다는 double(*)(double) 같은 함수 포인터로 암시적 변환이 되기 때문에 C API 콜백으로도 넘길 수 있습니다.
C++ code
// 불변 업데이트
#include <iostream>
int main() {
auto add_tax = [](double price) { return price * 1.1; };
std::cout << add_tax(20.0) << "\n";
}캡처가 없는 람다의 호출 연산자는 C++17부터 암묵적으로 constexpr이 될 수 있어, 상수 문맥에서도 그대로 쓸 수 있습니다.
함수 포인터로 변환한 람다를 C 콜백에 넘기면 상태를 담을 자리가 없으므로, 나중에 문맥이 필요해지면 인터페이스를 통째로 바꿔야 합니다.
03Maybe 스타일 처리
네 원소 {1, 2, 3, 3}을 왼쪽부터 더해 9를 찍는 코드로, 값이 없을 가능성을 다루는 장치는 들어 있지 않습니다. 실패를 값으로 표현하려면 C++17의 std::optional이 필요합니다. accumulate는 순차 왼쪽 접기가 규격으로 정해져 있고, 병렬 실행을 원하면 std::reduce를 써야 합니다.
C++ code
// Maybe 스타일 처리
#include <iostream>
#include <numeric>
#include <vector>
int main() {
std::vector<int> values = {1, 2, 3, 3};
std::cout << std::accumulate(values.begin(), values.end(), 0) << "\n";
}std::optional의 value_or는 인자를 항상 평가하므로, 대체값 계산이 비싸다면 has_value()로 갈라 쓰는 편이 낫습니다.
std::reduce는 이항 연산이 결합 법칙과 교환 법칙을 만족한다고 가정하므로, 뺄셈처럼 순서가 중요한 연산을 넘기면 결과를 신뢰할 수 없습니다.
04Result 스타일 매핑
람다 add_tax에 40.0을 넘겨 44를 출력하는 것이 전부이며, 성공과 실패를 구분해 감싸는 타입은 등장하지 않습니다. 변환 단계를 하나의 이름 있는 값으로 만드는 감각을 익히기에 알맞습니다. 반환 타입은 본문에서 double로 추론되므로 명시적인 -> 표기가 필요 없습니다.
C++ code
// Result 스타일 매핑
#include <iostream>
int main() {
auto add_tax = [](double price) { return price * 1.1; };
std::cout << add_tax(40.0) << "\n";
}람다 본문에 return이 여럿이고 타입이 서로 다르면 추론이 실패하므로, 그럴 때는 반환 타입을 화살표 표기로 직접 적어야 합니다.
결과를 int 변수에 담으면 소수부가 잘려 세금이 통째로 사라지므로, 부동소수 결과를 정수에 옮길 때는 반올림 규칙을 명시해야 합니다.
05fold 합계
{1, 2, 3, 5}를 accumulate로 접어 11을 얻는, fold의 가장 직접적인 형태입니다. 반복문 대신 합계 의도를 한 줄로 드러내고 싶을 때 선택합니다. 누산기는 값으로 복사되며 원본 벡터는 전혀 수정되지 않으므로, 큰 객체를 누산할 때는 복사 비용이 그대로 드러납니다.
C++ code
// fold 합계
#include <iostream>
#include <numeric>
#include <vector>
int main() {
std::vector<int> values = {1, 2, 3, 5};
std::cout << std::accumulate(values.begin(), values.end(), 0) << "\n";
}누산 대상이 std::string이면 매 단계 새 문자열이 만들어져 이차 시간이 되므로, 미리 reserve한 버퍼에 붙이는 편이 훨씬 빠릅니다.
람다가 인자를 값으로 받으면 원소가 클 때 단계마다 복사가 생기므로, 누산기는 이동하고 원소는 const&로 받도록 적어야 합니다.
06compose
합성 함수를 만드는 대신, 세율을 곱하는 람다 하나를 정의해 60.0에 적용합니다. 여러 단계를 이어 붙이려면 이런 단항 함수들을 준비해 두는 것이 출발점이 됩니다. 한 가지 함정은 1.1이 이진 부동소수로 정확히 표현되지 않는다는 점이라, 금액 계산에서는 미세한 오차가 누적될 수 있습니다.
C++ code
// compose
#include <iostream>
int main() {
auto add_tax = [](double price) { return price * 1.1; };
std::cout << add_tax(60.0) << "\n";
}금액처럼 정확도가 중요한 값은 double 대신 최소 단위 정수로 다루면, 반올림 시점을 코드가 직접 정할 수 있습니다.
여러 변환을 이어 붙일 때 단계마다 반올림하면 오차가 누적되므로, 반올림은 마지막 한 번으로 미루는 편이 안전합니다.
07partial 적용
인자를 미리 묶어 두는 코드는 없고, {1, 2, 3, 7}의 합 13을 출력합니다. 인자 일부를 고정하려면 람다로 감싸거나 std::bind_front(C++20)를 쓰면 됩니다. accumulate는 네 번째 인자로 이항 연산을 받을 수 있어, 기본 operator+ 대신 곱셈이나 사용자 정의 결합도 넣을 수 있습니다.
C++ code
// partial 적용
#include <iostream>
#include <numeric>
#include <vector>
int main() {
std::vector<int> values = {1, 2, 3, 7};
std::cout << std::accumulate(values.begin(), values.end(), 0) << "\n";
}std::bind_front는 앞쪽 인자만 고정해 자리표시자가 필요 없고, 예전 std::bind보다 오류 메시지도 훨씬 읽기 쉽습니다.
람다로 인자를 묶을 때 참조로 캡처하면 원본 변수가 먼저 사라진 뒤 호출될 수 있으므로, 오래 보관할 함수 객체에는 값 캡처가 맞습니다.
08curried 함수
여기 있는 람다는 인자를 하나만 받는 add_tax이며, 한 인자씩 나눠 받는 커링 구조는 아닙니다. 80.0을 넘기면 88이 나옵니다. auto로 받으면 클로저 타입이 그대로 유지돼 호출이 인라인될 여지가 있지만, std::function에 담는 순간 타입 소거가 끼어들어 간접 호출 비용이 생깁니다.
C++ code
// curried 함수
#include <iostream>
int main() {
auto add_tax = [](double price) { return price * 1.1; };
std::cout << add_tax(80.0) << "\n";
}함수 객체를 템플릿 인자로 받아 넘기면 클로저 타입이 그대로 유지되어, 호출부에서 본문이 인라인될 여지가 남습니다.
매개변수 타입을 std::function으로 선언해 두면 호출할 때마다 임시 래퍼가 만들어질 수 있어, 짧은 람다를 자주 넘기는 자리에서 비용이 큽니다.
09pipeline
파이프라인 대신 {1, 2, 3, 9}의 합 15를 계산해 출력하는 단일 단계 코드입니다. 여러 변환을 연결하는 표기는 C++20 <ranges>에서 |로 지원됩니다. 이 코드에서는 accumulate가 범위를 즉시 끝까지 순회하므로 중간 결과가 따로 남지 않습니다.
C++ code
// pipeline
#include <iostream>
#include <numeric>
#include <vector>
int main() {
std::vector<int> values = {1, 2, 3, 9};
std::cout << std::accumulate(values.begin(), values.end(), 0) << "\n";
}<ranges>의 뷰는 원소를 실제로 만들지 않고 필요할 때 계산하므로, 중간 컨테이너 없이 여러 단계를 이어 붙일 수 있습니다.
임시 컨테이너에 뷰를 걸어 두고 그 임시가 사라진 뒤 순회하면 이미 없어진 원소를 읽게 되므로, 뷰의 수명은 원본에 묶여 있음을 기억해야 합니다.
10lazy iterator
지연 평가와는 무관하게, 정의 직후 곧바로 호출되는 람다가 100.0에 대해 110을 내놓습니다. 계산을 값처럼 저장했다가 나중에 부르는 구조의 기본형입니다. 본문이 같아도 각 람다 표현식은 서로 다른 고유한 익명 타입을 만들기 때문에, 두 람다를 같은 변수에 번갈아 대입할 수는 없습니다.
C++ code
// lazy iterator
#include <iostream>
int main() {
auto add_tax = [](double price) { return price * 1.1; };
std::cout << add_tax(100.0) << "\n";
}여러 람다를 같은 변수에 번갈아 담아야 한다면 std::function이나 공통 함수 포인터 타입으로 받아 타입을 통일해야 합니다.
람다 타입은 이름을 적을 수 없어 멤버 변수 자리에 직접 쓸 수 없으므로, auto가 통하지 않는 곳에 저장하려면 방법을 따로 정해야 합니다.
11패턴 분기
분기 구문 없이 네 정수를 더해 17을 출력합니다. 값의 모양에 따라 갈라지는 처리는 std::variant와 방문자로 표현하는 것이 C++식 대응입니다. 한편 accumulate는 <numeric>에 있으므로, <algorithm>만 포함하면 컴파일이 되지 않습니다.
C++ code
// 패턴 분기
#include <iostream>
#include <numeric>
#include <vector>
int main() {
std::vector<int> values = {1, 2, 3, 11};
std::cout << std::accumulate(values.begin(), values.end(), 0) << "\n";
}std::visit에 넘기는 방문자는 모든 대안 타입을 처리해야 하며, 하나라도 빠지면 실행이 아니라 컴파일 단계에서 걸립니다.
std::get으로 현재 들어 있지 않은 대안을 꺼내면 std::bad_variant_access가 던져지므로, index()나 방문자로 갈라야 합니다.
12reducer 상태 누적
상태를 누적하는 reducer는 없고, 세금 계산 람다에 120.0을 넣어 132를 출력합니다. 이 람다는 캡처가 없어 호출 사이에 기억하는 값이 전혀 없습니다. 실제로 상태를 유지하려면 값을 캡처한 뒤 mutable을 붙여야 하는데, 그렇지 않으면 operator()가 const로 정의되어 수정이 막힙니다.
C++ code
// reducer 상태 누적
#include <iostream>
int main() {
auto add_tax = [](double price) { return price * 1.1; };
std::cout << add_tax(120.0) << "\n";
}mutable 람다를 복사하면 사본이 각자 상태를 따로 들고 가므로, 상태를 공유하려면 std::shared_ptr로 감싸 캡처해야 합니다.
상태를 지닌 람다를 여러 스레드에서 동시에 호출하면 캡처 변수에 데이터 경합이 생기므로, 알고리즘에 병렬 정책을 붙일 때 특히 위험합니다.
13all predicate
모든 원소가 조건을 만족하는지 보는 대신, {1, 2, 3, 13}을 더해 19를 냅니다. 술어 검사에는 std::all_of가 따로 있습니다. accumulate의 특성상 범위가 비어 있으면 아무 연산도 하지 않고 초기값을 그대로 돌려주므로, 빈 입력 처리가 자연스럽게 정의됩니다.
C++ code
// all predicate
#include <iostream>
#include <numeric>
#include <vector>
int main() {
std::vector<int> values = {1, 2, 3, 13};
std::cout << std::accumulate(values.begin(), values.end(), 0) << "\n";
}std::all_of는 빈 범위에서 참을, std::any_of는 거짓을 돌려주므로 빈 입력의 의미를 따로 적어 둘 필요가 없습니다.
술어 안에서 원소를 수정하면 알고리즘이 요구하는 조건을 어겨 결과가 보장되지 않으므로, 판정 함수는 값을 읽기만 해야 합니다.
14flatMap
중첩 구조를 펼치는 동작은 없으며, 단일 값 하나를 변환하는 람다를 140.0에 적용합니다. 결과는 154입니다. 이런 변환 함수를 컨테이너에 적용해 결과를 모으려면 std::transform에 출력 반복자나 std::back_inserter를 함께 넘겨야 합니다.
C++ code
// flatMap
#include <iostream>
int main() {
auto add_tax = [](double price) { return price * 1.1; };
std::cout << add_tax(140.0) << "\n";
}결과 개수가 입력과 달라지는 변환에는 std::transform 대신 반복문이나 <ranges>의 join을 쓰는 편이 의도가 분명합니다.
출력 반복자로 크기가 모자란 벡터의 begin()을 넘기면 범위를 넘어 쓰게 되므로, 미리 resize하거나 삽입 반복자를 써야 합니다.
15함수 값을 반환하는 함수
함수를 돌려주는 함수는 등장하지 않고, 벡터 {1, 2, 3, 15}의 합 21을 출력합니다. 계산 자체를 값으로 다루려면 람다를 반환하는 함수가 필요합니다. 덧붙이면 accumulate는 결합 순서가 왼쪽부터로 고정돼 있어, 순서를 규정하지 않는 std::reduce와 달리 부동소수 합계 결과가 항상 같게 재현됩니다.
C++ code
// 함수 값을 반환하는 함수
#include <iostream>
#include <numeric>
#include <vector>
int main() {
std::vector<int> values = {1, 2, 3, 15};
std::cout << std::accumulate(values.begin(), values.end(), 0) << "\n";
}람다를 돌려주는 함수의 반환 타입을 auto로 두면 클로저 타입이 그대로 유지되어, std::function으로 감쌀 때보다 호출이 가볍습니다.
반환하는 람다가 함수의 지역 변수를 참조로 캡처하면 돌아가는 순간 그 참조가 매달린 상태가 되어, 호출할 때 이미 사라진 값을 읽습니다.
정리하며
- ranges 뷰는 원본을 소유하지 않으므로 임시 객체에 붙인 채 보관하면 안 됩니다
- 빈 optional에 * 접근은 정의되지 않은 동작이고, value()는 예외를 던집니다
- 실패를 예외 대신 optional이나 variant 값으로 표현하면 호출부 흐름이 단순해집니다
- 파이프라인을 뷰로 구성하면 중간 컨테이너 할당 없이 한 번의 순회로 끝납니다
더 깊이 들어가고 싶다면 C++ 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.