RUST · 심층 가이드
Rust 함수형 완전 정리
순수 함수와 불변 업데이트에서 출발해 fold, compose, 커링, 게으른 이터레이터까지 Rust에서 함수형 스타일을 쓰는 15가지 방법을 다룹니다.
Rust의 클로저는 하나가 아니라 Fn, FnMut, FnOnce 세 트레이트로 갈립니다. 캡처한 값을 읽기만 하는지, 바꾸는지, 통째로 가져가는지에 따라 컴파일러가 알아서 등급을 정합니다. 함수형 언어처럼 클로저를 자유롭게 주고받으려다 막히는 지점은 대개 문법이 아니라 이 등급과 소유권이 맞지 않아서입니다.
순수 함수와 불변 업데이트로 기준을 잡은 뒤 fold 합계를 보면 반복문을 값의 축적으로 바꾸는 감각이 잡힙니다. 그 위에 compose와 partial 적용, curried 함수가 얹히는데, Rust에서는 이 세 가지가 다른 언어만큼 매끄럽지 않습니다. 클로저를 반환하려면 impl Fn(...)이나 Box<dyn Fn(...)>로 타입을 밝혀야 하기 때문입니다. 함수 값을 반환하는 함수 항목에서 이 형태를 직접 확인하십시오.
가장 자주 만나는 함정은 move 클로저입니다. 캡처한 값을 클로저 안으로 옮기면 바깥에서는 더 이상 그 값을 쓸 수 없고, 반대로 move를 빼면 참조를 캡처해 클로저가 원본보다 오래 살 수 없게 됩니다. 클로저를 반환하거나 스레드로 넘길 때는 거의 항상 move가 필요하고, 그 대신 캡처할 값을 미리 복제해 두는 판단이 따라옵니다.
01순수 함수
배열 [1, 2, 3, 1]을 iter()로 훑어 합계 7을 구해 출력합니다. 집계 로직을 반복문 없이 한 줄로 표현하고 싶을 때 쓰는 기본형입니다. let total: i32라는 타입 표기가 빠지면 컴파일되지 않는데, sum이 결과 타입에 대해 제네릭이라 컴파일러가 어느 정수형으로 모을지 결정할 수 없기 때문입니다.
Rust code
// 순수 함수
fn main() {
let values = [1, 2, 3, 1];
let total: i32 = values.iter().sum();
println!("{}", total);
}sum은 소비자라 이 자리에서 실제 순회가 일어나며, 같은 배열을 여러 번 접어야 한다면 iter()가 매번 새 이터레이터를 만들어 줍니다.
let total: i32에서 타입 표기를 빼면 어느 정수형으로 모을지 정할 수 없어 컴파일이 실패하므로, 표기하거나 터보피시로 적어야 합니다.
02불변 업데이트
가격에 1.1을 곱하는 클로저 add_tax를 만들어 20.0에 적용하고 22를 찍습니다. 계산 규칙에 이름을 붙여 여러 곳에서 재사용할 때 이런 작은 클로저가 편합니다. 이 클로저는 바깥 변수를 하나도 캡처하지 않아 Fn을 구현하며, 그 덕에 함수 포인터 fn(f64) -> f64로도 강제 변환됩니다.
Rust code
// 불변 업데이트
fn main() {
let add_tax = |price: f64| price * 1.1;
println!("{}", add_tax(20.0));
}이 클로저는 바깥 변수를 하나도 캡처하지 않아 Fn을 구현하고, 그 덕에 fn(f64) -> f64 함수 포인터로도 강제 변환됩니다.
세율을 상수 1.1로 박아 두면 값이 바뀔 때 코드를 고쳐야 하므로, 비율은 인자로 받아 두어야 같은 클로저를 다른 세율에 재사용할 수 있습니다.
03Maybe 스타일 처리
Option은 등장하지 않고, 원소 네 개짜리 배열의 총합 9를 계산해 보여 줍니다. 값이 확실히 존재하는 컬렉션을 다룰 때는 이렇게 곧장 접어 버리는 편이 간결합니다. iter()가 내놓는 항목은 값이 아니라 &i32 참조이며, 소유권째 꺼내고 싶다면 배열에도 쓸 수 있는 into_iter()를 고릅니다.
Rust code
// Maybe 스타일 처리
fn main() {
let values = [1, 2, 3, 3];
let total: i32 = values.iter().sum();
println!("{}", total);
}iter()가 내놓는 항목은 &i32이고, 값을 그대로 꺼내고 싶다면 배열에도 쓸 수 있는 into_iter()를 고르면 됩니다.
값이 없을 수 있는 계산을 흉내 내려고 합계 0을 없음으로 취급하면, 실제로 합이 0인 정상 입력과 구분되지 않아 판단이 어긋납니다.
04Result 스타일 매핑
Result 매핑 대신, 세금 계산 클로저를 정의하고 40.0을 넣어 44를 얻는 흐름입니다. 입력 하나를 받아 값을 변형하는 규칙을 변수에 담아 두는 방식이 여기 해당합니다. 매개변수에 f64를 적어 둔 점이 핵심인데, 클로저 타입은 첫 호출에서 굳어지므로 나중에 정수를 넘기면 같은 클로저를 다시 쓸 수 없습니다.
Rust code
// Result 스타일 매핑
fn main() {
let add_tax = |price: f64| price * 1.1;
println!("{}", add_tax(40.0));
}매개변수에 f64를 명시해 두면 첫 호출을 기다리지 않고 타입이 정해져, 정의부만 봐도 무엇을 받는 클로저인지 분명해집니다.
클로저의 인자 타입은 첫 사용에서 굳어지므로, 같은 클로저에 정수를 한 번 넘기고 나면 실수를 넘기는 호출이 타입 오류로 막힙니다.
05fold 합계
제목대로 접어서 더하기는 하지만 실제 호출은 fold가 아니라 sum()이고, 결과는 11입니다. 초기값과 누적 규칙을 따로 쓸 이유가 없는 단순 합계라면 이 쪽이 읽기 좋습니다. 내부적으로는 0에서 시작해 덧셈으로 접는 fold와 같은 일을 하며, 이터레이터를 끝까지 소비하고 사라지게 만듭니다.
Rust code
// fold 합계
fn main() {
let values = [1, 2, 3, 5];
let total: i32 = values.iter().sum();
println!("{}", total);
}sum은 0에서 시작해 덧셈으로 접는 fold와 같은 일을 하며, 누적 규칙이 덧셈뿐이라면 이름이 뜻을 더 잘 드러냅니다.
정수 합계는 릴리스 빌드에서 넘쳐도 패닉 없이 감싸 도므로, 값이 커질 수 있다면 i64로 모으거나 checked_add로 접어야 합니다.
06compose
함수 합성을 다루지 않고, 곱셈 한 번짜리 클로저를 60.0에 적용해 66을 출력합니다. 변환 규칙이 하나뿐일 때는 굳이 합성 도구를 끌어오지 않아도 충분합니다. 두 클로저를 이어 붙이려면 결과 타입에 이름을 붙일 수 없다는 문제가 생기고, 그때는 impl Fn 반환이나 Box<dyn Fn>이 필요해집니다.
Rust code
// compose
fn main() {
let add_tax = |price: f64| price * 1.1;
println!("{}", add_tax(60.0));
}두 변환을 이어 붙이려면 move |x| g(f(x))처럼 새 클로저로 감싸면 되고, 반환할 때는 이름 없는 타입 대신 impl Fn을 적습니다.
합성한 클로저를 구조체 필드에 담으려 하면 타입에 이름이 없어 막히므로, 제네릭 매개변수를 두거나 Box<dyn Fn>으로 감싸야 합니다.
07partial 적용
부분 적용은 나오지 않으며, [1, 2, 3, 7]의 합 13을 산출하는 짧은 코드입니다. 고정된 데이터의 통계를 뽑아 보는 스크립트성 작업에 그대로 맞습니다. 이 배열은 힙을 쓰지 않는 고정 크기 [i32; 4]라서 Vec과 달리 할당이 없고, 크기가 타입에 박혀 있어 길이도 컴파일 시점에 결정됩니다.
Rust code
// partial 적용
fn main() {
let values = [1, 2, 3, 7];
let total: i32 = values.iter().sum();
println!("{}", total);
}[i32; 4] 배열은 힙을 쓰지 않아 할당이 없고 길이가 타입에 들어 있어, 크기가 고정된 데이터에는 Vec보다 가볍습니다.
배열은 길이가 타입의 일부라 원소 수가 다르면 같은 함수에 넘길 수 없으므로, 길이에 상관없이 받으려면 &[i32] 슬라이스로 선언해야 합니다.
08curried 함수
커링 구조 없이 인자 하나를 받는 클로저가 80.0을 88로 바꿔 놓습니다. 단가에 일정 비율을 얹는 계산을 한곳에 모아 둘 때 이 형태면 충분합니다. 클로저마다 컴파일러가 이름 없는 고유 타입을 새로 만들기 때문에, 구조체 필드에 저장하려면 제네릭 매개변수를 두거나 트레이트 객체로 감싸야 합니다.
Rust code
// curried 함수
fn main() {
let add_tax = |price: f64| price * 1.1;
println!("{}", add_tax(80.0));
}클로저마다 컴파일러가 이름 없는 고유 타입을 새로 만들기 때문에, 생김새가 똑같은 두 클로저도 서로 다른 타입으로 취급됩니다.
조건에 따라 두 클로저 중 하나를 골라 같은 변수에 담으려 하면 타입이 달라 컴파일되지 않고, Box<dyn Fn>으로 맞춰 주어야 합니다.
09pipeline
파이프라인 대신 배열 하나를 훑어 15라는 합계를 만들어 냅니다. 중간 변환 없이 곧장 결과만 필요할 때 이렇게 짧게 끝내면 됩니다. map이나 filter 같은 어댑터는 값을 요구받기 전까지 아무 일도 하지 않는 반면, sum은 소비자이므로 이 자리에서 순회가 실제로 일어납니다.
Rust code
// pipeline
fn main() {
let values = [1, 2, 3, 9];
let total: i32 = values.iter().sum();
println!("{}", total);
}map이나 filter는 값을 요구받기 전까지 아무 일도 하지 않으므로, 여러 단계를 이어 붙여도 중간 벡터가 생기지 않습니다.
단계마다 collect로 벡터를 만들어 넘기면 지연 평가가 끊겨, 단계 수만큼 불필요한 할당과 순회가 늘어납니다.
10lazy iterator
지연 평가와는 무관하게, 100.0에 세율을 곱한 결과를 그대로 찍는 예제입니다. 금액 계산 규칙을 코드 한 줄로 고정해 두려는 상황에 해당합니다. 출력은 110이 아니라 110.00000000000001이 되는데, 1.1을 이진 부동소수점으로 정확히 표현할 수 없어서입니다. 돈은 정수 최소 단위로 다루는 편이 안전합니다.
Rust code
// lazy iterator
fn main() {
let add_tax = |price: f64| price * 1.1;
println!("{}", add_tax(100.0));
}이진 부동소수점은 1.1을 정확히 담지 못해 결과에 미세한 오차가 남으므로, 금액은 최소 단위 정수로 다루고 표시할 때만 형식을 입히는 편이 안전합니다.
이런 계산 결과를 ==로 비교하면 눈에 보이지 않는 오차 때문에 같아야 할 값이 다르게 판정되어, 검증 로직이 이유 없이 실패합니다.
11패턴 분기
패턴 분기 문법은 없고, 네 원소의 합 17을 계산해 출력하는 코드만 있습니다. 조건 분기 없이 전체를 한 값으로 줄이는 집계에 알맞습니다. match로 갈랐다면 각 갈래의 타입이 모두 같아야 하고 모든 경우를 덮어야 하지만, 여기서는 그런 제약 없이 이터레이터 하나로 끝납니다.
Rust code
// 패턴 분기
fn main() {
let values = [1, 2, 3, 11];
let total: i32 = values.iter().sum();
println!("{}", total);
}값을 하나로 줄이는 집계는 분기가 없으므로, match가 요구하는 갈래 간 타입 일치나 완전성 조건을 신경 쓸 필요가 없습니다.
빈 배열에 sum을 부르면 오류가 아니라 0이 나오므로, 데이터가 비어 있는 상황과 합이 0인 상황을 결과만 보고는 구분할 수 없습니다.
12reducer 상태 누적
상태를 누적하는 리듀서는 보이지 않고, 캡처 없는 세금 클로저가 120.0을 132로 만듭니다. 호출 사이에 기억할 것이 없는 순수한 변환에 딱 맞는 크기입니다. 바깥 변수를 고쳐 쓰지 않으므로 FnMut이 아니라 Fn이면 충분하고, 호출할 때 mut 바인딩도 필요 없습니다.
Rust code
// reducer 상태 누적
fn main() {
let add_tax = |price: f64| price * 1.1;
println!("{}", add_tax(120.0));
}이 클로저는 바깥 변수를 고쳐 쓰지 않아 Fn이면 충분하고, 호출하는 쪽 바인딩에 mut을 붙일 필요도 없습니다.
누적 상태를 넣으려고 바깥 변수를 클로저 안에서 갱신하면 FnMut이 되어, 그 클로저를 담는 변수에 mut이 없으면 호출조차 되지 않습니다.
13all predicate
술어를 검사하는 all은 쓰이지 않고, 배열 합 19를 구하는 코드가 대신 놓여 있습니다. 조건 판정이 아니라 전체 값을 하나로 모으는 작업이라면 이 형태가 맞습니다. 참고로 all은 거짓을 만나는 순간 남은 원소를 건너뛰지만, sum은 언제나 끝까지 순회한다는 점이 다릅니다.
Rust code
// all predicate
fn main() {
let values = [1, 2, 3, 13];
let total: i32 = values.iter().sum();
println!("{}", total);
}all은 거짓을 만나는 순간 남은 원소를 건너뛰지만 sum은 항상 끝까지 순회하므로, 조기 종료가 필요하면 어댑터 선택이 중요합니다.
빈 이터레이터에 all을 부르면 조건과 상관없이 참이 되므로, 항목이 하나도 없는 경우를 통과로 봐도 되는지 먼저 정해야 합니다.
14flatMap
중첩 구조를 펴는 동작은 없으며, 단일 값 하나를 받아 154를 내놓는 클로저 호출입니다. 값 대 값 변환만 필요한 자리에서 가장 부담이 적은 방식입니다. flat_map이라면 각 원소가 다시 이터레이터를 내놓아야 하고 그만큼 중첩된 타입을 다뤄야 하지만, 여기서는 그런 층이 전혀 없습니다.
Rust code
// flatMap
fn main() {
let add_tax = |price: f64| price * 1.1;
println!("{}", add_tax(140.0));
}flat_map은 각 원소가 다시 이터레이터를 내놓아야 하므로, 값 대 값 변환뿐이라면 map으로 충분하고 타입도 단순해집니다.
flat_map에 Vec을 만들어 돌려주는 클로저를 넘기면 원소마다 임시 할당이 생기므로, 슬라이스를 iter()로 흘려보내는 편이 낫습니다.
15함수 값을 반환하는 함수
함수를 돌려주는 코드가 아니라 [1, 2, 3, 15]의 합 21을 출력하는 코드입니다. 값 계산으로 충분한 곳에 굳이 고차 함수를 끌어올 이유는 없습니다. 만약 정말 함수 값을 반환해야 한다면 클로저 타입에 이름이 없다는 벽을 만나게 되고, 반환 위치에 impl Fn을 적거나 힙에 담는 선택이 뒤따릅니다.
Rust code
// 함수 값을 반환하는 함수
fn main() {
let values = [1, 2, 3, 15];
let total: i32 = values.iter().sum();
println!("{}", total);
}함수를 돌려줘야 한다면 반환 위치에 impl Fn(i32) -> i32를 적어 힙 할당 없이 넘길 수 있고, 호출자는 구체 타입을 몰라도 됩니다.
조건에 따라 서로 다른 클로저를 반환하려 하면 impl Fn이 하나의 구체 타입만 가리키므로 컴파일이 막히고, 박싱한 트레이트 객체가 필요해집니다.
정리하며
- 클로저는 캡처 방식에 따라 Fn·FnMut·FnOnce로 나뉘며 이것이 제약의 출발점입니다
- 클로저를 반환하려면 impl Fn 또는 Box dyn Fn으로 타입을 밝혀야 합니다
- move는 소유권을 옮기므로 바깥에서 쓸 값은 미리 복제해 둡니다
- fold는 반복문을 값의 축적으로 바꾸는 가장 기본적인 도구입니다
더 깊이 들어가고 싶다면 Rust 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.