C++ 함수형 프로그래밍 심화 — 고차 함수·모나드·Ranges

이 글의 핵심

C++ 표준 라이브러리 관점에서 함수형 스타일의 의미론(지연·부작용·오류)과, 람다·고차 함수·Ranges·모나드 API를 실무에 맞게 쓰는 방법을 정리합니다.

함수형 프로그래밍(FP)은 값과 함수의 조합으로 프로그램을 구성하며, 부작용을 경계에 모으는 사고방식입니다. C++은 순수 FP 언어가 아니지만, C++11의 람다와 std::function, C++17의 std::optional, C++20의 Ranges, C++23의 std::expected와 모나딕 연산으로 표준 라이브러리만으로도 FP 스타일을 일관되게 쓸 수 있습니다. 아래에서는 문법 나열이 아니라, 객체 모델·수명·지연 평가·오류 채널이 어떻게 맞물리는지 중심으로 설명합니다.

고차 함수와 클로저의 내부

고차 함수(higher-order function)는 함수를 인자로 받거나 함수를 반환하는 함수입니다. C++에서는 함수 포인터, 함수 객체(functor), 람다가 그 역할을 합니다. 람다 표현식은 컴파일러가 고유한 클로저 타입(이름 없는 함수 객체 타입)을 만들고, 캡처 목록에 따라 멤버 변수로 환경을 복사하거나 참조로 묶습니다.

값 캡처(=)는 복사 생성·이동에 따른 비용이 있으며, 참조 캡처(&)는 수명 문제를 직접 안고 갑니다. 특히 지역 변수를 참조 캡처한 람다를 비동기·콜백으로 넘기면 댕글링 참조가 되기 쉽습니다. 반면 값 캡처는 비용이 있어도 소유권과 수명을 람다 내부로 가져오는 안전한 기본 패턴입니다.

다만 “값 캡처는 안전하다”에는 중요한 예외가 있습니다. 멤버 함수 안에서 [=]로 쓴 람다가 멤버 변수 count_를 사용하면, 복사되는 것은 count_가 아니라 this 포인터입니다. 람다가 객체보다 오래 살아 스레드나 이벤트 큐에서 나중에 실행되면 해제된 객체의 멤버를 읽게 되는데, 코드에는 [=]만 보여 값 캡처라고 착각하기 쉽습니다. C++20은 이 암시적 this 캡처를 폐기 예정(deprecated)으로 지정해 경고를 내므로, 의도를 드러내려면 [this](포인터 공유)나 [*this](C++17, 객체 전체 복사), 또는 [count = count_]처럼 필요한 멤버만 초기화 캡처로 복사하는 방식을 명시적으로 고르는 것이 좋습니다. 또 캡처한 값을 람다 안에서 수정하려면 mutable이 필요한데, 이렇게 상태를 가진 람다를 알고리즘에 넘기면 알고리즘이 내부에서 람다를 복사할 수 있어 수정한 상태가 호출자에게 돌아오지 않는다는 점도 FP 스타일과 잘 맞지 않는 부분입니다.

std::function<R(Args...)>는 타입 소거(type erasure)를 제공해 서로 다른 콜러블을 동일한 인터페이스로 담을 수 있습니다. 대가로 간접 호출(indirect call)과 구현에 따라 소형 버퍼 최적화(SBO) 밖의 힙 할당이 발생할 수 있습니다. 성능이 중요한 경로에서는 템플릿으로 콜러블을 그대로 받거나, 시그니처가 고정이면 함수 포인터·구체적인 함수 객체 타입을 쓰는 편이 예측 가능합니다. 또 std::function은 담긴 콜러블이 복사 가능해야 하므로 unique_ptr을 캡처한 람다는 담을 수 없는데, C++23의 std::move_only_function이 이 제약을 없앱니다.

아래는 고차 함수가 다른 콜러블을 매개변수로 받아 동일한 제어 흐름을 재사용하는 전형적인 패턴입니다.

#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>

template <class F>
void for_each_pair(const std::vector<int>& v, F&& f) {
  for (std::size_t i = 0; i + 1 < v.size(); ++i) {
    f(v[i], v[i + 1]);
  }
}

int main() {
  std::vector<int> xs{1, 2, 3, 4};
  int sum_adjacent = 0;
  // 클로저: sum_adjacent를 참조 캡처 (블록 스코프·동기 호출이므로 안전한 경우)
  for_each_pair(xs, [&](int a, int b) { sum_adjacent += a + b; });
  std::cout << sum_adjacent << '\n';  // (1+2)+(2+3)+(3+4) = 15
}

for_each_pair는 알고리즘의 뼈대만 고정하며, 무엇을 할지는 호출자가 넘긴 콜러블에 맡깁니다. 템플릿 F&&는 std::function 없이도 인라인 최적화가 가능해지는 대신, 바이너리 크기가 늘 수 있으므로 공통 경로 한두 곳에만 쓰는지, 헤더 전역 템플릿으로 퍼질지를 팀 규모에 맞게 조율하는 것이 좋습니다.

커링과 부분 적용

커링(currying)은 다인자 함수를 단일 인자 함수의 연쇄로 바꾸는 연산이며, 부분 적용(partial application)은 인자 일부를 미리 묶어 새로운 함수를 만드는 것입니다. C++ 표준에는 하스켈 스타일의 자동 커링이 없으므로, 실무에서는 std::bind, C++20 std::bind_front, 또는 람다가 다른 람다를 반환하는 형태로 표현합니다.

bind_front(f, a)는 f(a, ...) 형태로 고정할 인자를 앞에서 묶으며, 나머지 호출 시점 인자가 뒤에 붙습니다. 플레이스홀더(_1, _2)를 쓰는 std::bind는 여전히 유효하지만, 가독성과 컴파일 오류 메시지 면에서 bind_front와 명시적 람다가 선호되는 경우가 많습니다.

#include <functional>
#include <iostream>

int add_three(int a, int b, int c) { return a + b + c; }

int main() {
  using namespace std::placeholders;
  auto g = std::bind(add_three, 10, _1, _2);  // 첫 인자만 고정
  std::cout << g(2, 3) << '\n';               // add_three(10,2,3) => 15

  auto h = std::bind_front(add_three, 5);     // 첫 인자 고정, 나머지 2개는 호출 시
  std::cout << h(1, 2) << '\n';                // 8
}

수동 커링은 인자를 하나씩 받는 람다를 중첩해 표현합니다. 템플릿과 함께 쓰면 다형 콜러블까지 묶을 수 있지만, C++에서는 가독성·디버깅 비용이 커지기 쉬우므로, API 경계(설정 주입, 테스트 더블)처럼 진짜로 “인자를 나누는 것”이 이득인 지점에만 두는 편이 안전합니다.

Ranges와 지연 평가

C++20 Ranges는 반복자 쌍 대신 Range 개념을 일급으로 다루고, 파이프 |로 뷰 어댑터를 연결합니다. 핵심은 많은 views::* 연산이 즉시 컨테이너를 만들지 않고, 필요할 때 다음 원소를 계산하는 지연(lazy) 시맨틱을 갖는다는 점입니다. 따라서 filter·transform·take 등을 길게 이어도 중간 vector를 매 단계마다 만들지 않아도 됩니다.

지연 평가는 성능상 이점인 동시에 부작용과 결합하면 직관과 어긋날 수 있습니다. 예를 들어 transform 안에서 공유 상태를 바꾸면, 실제로 원소를 소비하는 순서·횟수에 따라 결과가 달라질 수 있습니다. FP 스타일에서는 뷰 체인 안에서는 순수 변환을 유지하며, 입출력·로깅·뮤텍스는 명시적으로 materialize한 이후나 별도 경계 함수로 두는 것이 안전합니다.

#include <iostream>
#include <ranges>
#include <vector>

namespace vw = std::views;

int main() {
  std::vector<int> v{1, 2, 3, 4, 5, 6};
  auto r = v | vw::filter([](int x) { return x % 2 == 0; })
           | vw::transform([](int x) { return x * x; })
           | vw::take(2);
  for (int x : r) {
    std::cout << x << ' ';  // 첫 짝수 두 개만: 4 16
  }
}

내부적으로 뷰는 보통 기저 시퀀스의 반복자를 감싼 가벼운 객체이며, 반복자 무효화 규칙은 기저 컨테이너를 따릅니다. vector에 대한 filter 뷰를 들고 있는 동안 원본을 reallocate시키면 뷰 무효화로 이어질 수 있으므로, 장수명 파이프라인에서는 소유 컨테이너와 뷰의 수명을 함께 설계해야 합니다. 최종적으로 인덱스 접근·다중 패스가 필요하면 std::ranges::to(C++23) 등으로 명시적으로 materialize하는 지점을 코드에 박아 두는 것이 디버깅에 유리합니다.

지연 평가에는 직관과 다른 비용 모델도 따라옵니다. 위 예제에서 순서를 바꿔 transform을 filter 앞에 두면, transform_view는 결과를 저장하지 않고 역참조할 때마다 함수를 다시 호출하므로, filter가 조건을 검사할 때 한 번, 반복문이 값을 읽을 때 또 한 번 해서 조건을 통과한 원소에 대해 transform 함수가 두 번씩 호출됩니다. 변환이 무겁거나 부작용이 있다면 결과가 달라지므로, “비싼 변환은 거르기 뒤에” 두는 것이 원칙입니다. filter_view는 첫 원소를 찾는 비용을 한 번만 치르려고 begin() 결과를 내부에 캐시하는데, 이 때문에 const 뷰로는 순회할 수 없어 const auto& r = ...로 받아 for문을 돌리면 no matching function for call to 'begin' 류의 긴 컴파일 에러가 납니다. 같은 이유로 한 번 순회를 시작한 뒤 기저 컨테이너를 바꾸면 캐시된 위치가 틀어집니다. 또 filter 뷰를 통해 원소를 수정해서 그 원소가 조건을 더 이상 만족하지 않게 만드는 것은 정의되지 않은 동작입니다. 이런 규칙 때문에 뷰를 멤버 변수로 오래 들고 다니면 문제가 생기기 쉽습니다. 뷰는 함수 안에서 만들어 바로 소비하는 지역적 표현으로 쓰고, 함수 경계를 넘길 때는 컨테이너로 materialize하는 규칙을 두는 편이 안전합니다.

모나드 스타일: std::optional과 std::expected

함수형 언어의 모나드(monad)는 한 마디로 “맥락을 끼워 넣은 값”에 대한 합성 규칙입니다. C++에서는 optional(값 있음/없음)과 C++23 expected<T, E>(값/오류)가 그런 맥락을 표준으로 제공합니다. C++23부터 optional·expected는 and_then, transform, or_else 같은 모나딕 연산을 지원해, 중첩 if 없이 성공 경로를 파이프처럼 이을 수 있습니다.

Haskell의 Maybe/Either처럼 타입 클래스로 일반화된 것은 아니지만 동작은 비슷합니다. transform은 값이 있으면 함수를 적용해 결과를 다시 감싸고, 비어 있거나 오류면 그대로 전달합니다. and_then은 그 자체로 optional/expected를 반환하는, 즉 실패할 수 있는 다음 계산에 값을 넘기며, 앞 단계가 실패했으면 호출하지 않습니다. or_else는 반대로 실패했을 때만 호출되어 대체 값이나 다른 오류를 돌려줍니다. expected에는 오류 쪽만 변환하는 transform_error도 있습니다.

optional을 반환하는 함수를 transform에 넣으면 결과가 optional<optional<T>>가 되므로, 실패할 수 있는 단계는 and_then, 단순 변환은 transform으로 나누는 것이 구분 기준입니다.

#include <expected>
#include <iostream>
#include <optional>
#include <string_view>

std::optional<int> parse_positive(std::string_view s) {
  if (s.empty()) return std::nullopt;
  int v = 0;
  for (char c : s) {
    if (c < '0' || c > '9') return std::nullopt;
    v = v * 10 + (c - '0');
  }
  return v > 0 ? std::optional{v} : std::nullopt;
}

enum class ParseErr { empty, not_positive, not_digit };

std::expected<int, ParseErr> parse_positive_ex(std::string_view s) {
  if (s.empty()) return std::unexpected{ParseErr::empty};
  int v = 0;
  for (char c : s) {
    if (c < '0' || c > '9') return std::unexpected{ParseErr::not_digit};
    v = v * 10 + (c - '0');
  }
  if (v <= 0) return std::unexpected{ParseErr::not_positive};
  return v;
}

int main() {
  auto o = parse_positive("42")
               .transform([](int x) { return x * 2; })
               .and_then([](int x) -> std::optional<int> {
                 return x < 100 ? std::optional{x} : std::nullopt;
               });

  // 빈 문자열로 실패 → or_else에서 기본값으로 복구
  auto e = parse_positive_ex("")
               .transform([](int x) { return x + 1; })
               .or_else([](ParseErr err) -> std::expected<int, ParseErr> {
                 if (err == ParseErr::empty) return 0;  // 도메인 정책에 맞는 대체값
                 return std::unexpected{err};
               });

  if (o) std::cout << "o=" << *o << '\n';
  if (e) std::cout << "e=" << *e << '\n';  // e==0 (empty 입력 복구)
}

예제는 모나딕 체인의 형태를 보여 주기 위해 파싱을 단순화했습니다. v = v * 10 + (c - '0')은 자릿수가 많은 입력에서 int 오버플로(정의되지 않은 동작)를 일으키므로, 실제 코드라면 std::from_chars로 파싱하고 그 결과의 std::errc::result_out_of_range를 expected의 오류로 옮기는 편이 정확합니다. 체인의 두 결과도 비교해 볼 만합니다. o는 "42" → 84 → 조건 통과로 값이 남고, e는 빈 문자열에서 empty 오류가 난 뒤 transform을 건너뛰고 or_else에서 0으로 복구됩니다. or_else가 성공한 값에는 아무 일도 하지 않고, transform이 실패한 값에는 아무 일도 하지 않는다는 대칭 덕분에 각 단계는 자기 경로만 신경 쓰면 됩니다.

optional과 expected 중 무엇을 반환할지는 호출자가 실패 이유에 따라 다르게 행동해야 하는가로 판단하면 됩니다. “찾았다/못 찾았다”처럼 실패가 하나뿐이면 optional이 간단하고, 빈 입력은 기본값으로 복구하되 숫자가 아닌 입력은 사용자에게 알려야 하는 식이라면 이유를 담는 expected가 필요합니다. expected의 value()는 값이 없을 때 std::bad_expected_access를 던지고 *e는 검사 없이 접근해 정의되지 않은 동작이 되므로, 체인의 끝에서는 if (e)로 확인하거나 value_or를 쓰는 습관이 필요합니다.

오류 타입 E는 성공 경로에서도 expected 객체 크기에 포함되므로(값과 오류가 공용체로 저장됨) 가볍게 유지하는 것이 좋습니다. 오류마다 긴 std::string을 만들기보다 작은 도메인 전용 enum class나 std::error_code를 E로 두고, 사람이 읽을 메시지는 경계에서 만드는 패턴이 흔합니다.

프로덕션에서의 함수형 C++ 관용구

현실적인 타협은 데이터 흐름에는 FP, 자원과 동시성에는 명령형을 쓰는 혼합입니다. 파싱·검증·데이터 변환 파이프라인은 optional/expected와 Ranges로 선언적으로 쓰고, 락·스레드 풀·메모리 풀 같은 자원은 RAII와 명시적인 상태 기계로 다룹니다.

핫 루프에서는 std::function 같은 타입 소거를 피하고, 인라인될 수 있는 람다를 표준 알고리즘이나 ranges:: 알고리즘에 직접 넘기는 것이 기본입니다. 반대로 이벤트 핸들러 등록처럼 호출 빈도가 낮고 서로 다른 콜러블을 저장해야 하는 API 경계에서는 std::function의 단순함이 더 중요합니다. 지연 Ranges 파이프라인은 중간 컨테이너를 줄여 주지만 디버거에서 중간 결과를 보기 어려우므로, 필요하면 std::ranges::to<std::vector>()로 스냅샷을 찍을 수 있는 지점을 남겨 둡니다. 오류 처리에서는 예외가 허용되는 코드베이스라면 예외를 계속 쓰되, 예외를 금지하는 실시간·임베디드 코드나 ABI가 다른 플러그인 경계에서는 expected로 오류를 값으로 전달하는 편이 맞습니다.

컴파일러 관점의 비용

람다는 이름 없는 함수 객체 타입으로 내려가고 캡처는 그 멤버 변수가 됩니다. 템플릿으로 넘긴 람다는 대개 인라인되어 추가 비용이 없지만, std::function으로 감싸면 간접 호출이 생기고 인라인이 막힙니다. Ranges 뷰 파이프라인은 각 단계가 중첩된 템플릿 타입으로 전개되므로 긴 파이프라인을 헤더에 많이 두면 컴파일 시간과 에러 메시지 길이가 크게 늘어납니다. optional/expected의 모나딕 연산은 최적화 빌드에서 손으로 쓴 if 분기와 비슷한 코드로 정리되는 것이 보통이지만, 디버그 빌드에서는 람다와 래퍼 함수 호출이 그대로 남아 느릴 수 있습니다.

트러블슈팅

증상흔한 원인대응
Ranges 파이프에서 이상한 순서/중복 호출지연 평가 + 부작용 혼합materialize 지점 고정, 순수 변환으로 분리
std::function에서만 느림타입 소거·힙 할당템플릿 F&&·함수 포인터로 치환 실험
expected 체인이 컴파일만 되고 읽기 어려움과도한 and_then 중첩중간 타입 도입·얕은 함수로 분해
람다 캡처 후 크래시참조 캡처나 암시적 this 캡처의 수명값 캡처·초기화 캡처, 필요하면 shared_ptr로 수명 연장
const 뷰를 순회할 수 없다는 컴파일 에러filter_view 등의 begin() 캐시뷰를 const가 아닌 값이나 전달 참조로 받기

같이 보면 좋은 글