/ PROGRAMMING, RUST

Rust - Functional Programming

Rust는 여러 프로그래밍 언어로부터 디자인에 영향을 받았는데, 그 중 함수형 프로그래밍의 영향도 크게 받았습니다. 함수를 값으로 취급하여, argument로써의 전달, return value로의 반환, 변수에 할당 등을 가능하게 하는 것이 그 중요한 특징입니다. 이러한 특징은 Rust의 ClosuresIterators를 통해 구현되었습니다.

Rust 맛보기

Closures

이름이 없는 함수로, 다른 프로그래밍 언어에서 lambda 함수라고 불리는 그것에 해당한다고 볼 수 있습니다. 함수와 달리 closure는 자신이 속한 환경(scope) 내의 변수를 capture할 수 있습니다. 이를 통해 closure는 함수와 달리 외부 변수에 의존하는 코드를 작성할 수 있습니다.

Closure Syntax

let plus_one = |x: i32| x + 1;
let a = plus_one(5);

let foo_v3 = |x: i32| {
    let mut result = x;
    result += 1;
    result
};

위와 같이 parameter는 || 안에 선언하며, return expression은 {}로 감싸서 표현이 가능합니다.

Parameter와 리턴 값에 type annotation을 생략 가능하지만, 처음으로 호출되는 지점에서 compile time에 type이 결정되기 때문에 다른 type의 argument를 넘겨 줄 수 없으니 주의해야 합니다. 그리고 type annotation이 없는데 한 번도 호출되지 않는 closure는 타입 추론이 불가능하므로, 컴파일이 되지 않습니다.

Closure를 활용한 Environment Capture

Closure의 가장 큰 특징은 argument로 전달되지 않은 외부 변수도 사용할 수 있다는 것입니다. 이를 Environment Capture라고 합니다.

Closure를 활용해 Environment Capture를 수행하면 high readability(Parameter passing 대비), high safety(프로그래머가 신경써야 하는 부분이 적어짐)를 얻을 수 있습니다.

아래 간단한 예시 코드를 통해 Closure의 활용례를 살펴보겠습니다.

요청받은 색깔의 셔츠가 있으면 해당 셔츠의 재고가 남아있을 경우 해당 색깔을 리턴하고, 없을 경우 가장 많이 재고가 있는 색깔을 리턴하는 코드입니다.

#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
    Red,
    Blue,
    Green,
}

struct Inventory {
    shirts: Vec<ShirtColor>,
}
impl Inventory {
fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
user_preference.unwrap_or_else(|| self.most_stocked())
}
fn most_stocked(&self) -> ShirtColor {
let mut num_red = 0;
let mut num_blue = 0;
for color in &self.shirts {
match color {
ShirtColor::Red => num_red += 1,
ShirtColor::Blue => num_blue += 1,
}
}
if num_red > num_blue {
ShirtColor::Red
} else {
ShirtColor::Blue
}
}
}
fn main() {
let store = Inventory {
shirts: vec![ShirtColor::Blue, ShirtColor::Red, ShirtColor::Blue],
};
let user_pref1 = Some(ShirtColor::Red);
let giveaway1 = store.giveaway(user_pref1);
println!(
"The user with preference {:?} gets {:?}",
user_pref1, giveaway1
);
let user_pref2 = None;
let giveaway2 = store.giveaway(user_pref2);
println!(
"The user with preference {:?} gets {:?}",
user_pref2, giveaway2
);
}

.unwrap_or_else()라는 Option의 메소드가 등장했네요. 이 메소드는 argument가 없는 closure를 받아서, OptionNone일 때 closure를 호출해 그 반환값을 그대로 반환합니다. 위에서 호출하는 closure의 형태를 보시면, argument가 존재하지 않는데도 self의 immutable reference에 접근하여 most_stocked() 메소드를 호출하고 있습니다. 이는 Closure가 Environment Capture를 통해 외부 변수에 접근할 수 있기 때문에 가능한 일입니다.

Closure는 capture된 값을 3가지 방식으로 다룹니다.

  1. Borrowing immutably

     fn main() {
         let list = vec![1, 2, 3];
         let only_borrows = || println!("{:?}", list); // list를 immutable하게 borrow
         only_borrows();
         println!("{:?}", list);
     }
    
  2. Borrowing mutably

     fn main() {
         let mut list = vec![1, 2, 3];
         let mut_borrows = || list.push(4); // list를 mutable하게 borrow
         mut_borrows();
         println!("{:?}", list);
     }
    

    주의할 점은, closure mut_borrows의 정의 시점에서 list를 mutable하게 borrow하고 있기 때문에, mut_borrows() 호출 전까지 list에 대한 mutable reference가 존재하게 됩니다. 이는 mut_borrows() 호출 전까지 list에 대한 다른 reference를 생성할 수 없음을 의미합니다. 즉, mut_borrows 정의와 mut_borrows() 호출 사이에 println! 등으로 list에 대한 reference를 생성하면 컴파일 에러가 발생합니다.

  3. Taking ownership

    move 키워드를 앞에 추가하여 capture된 값의 소유권을 Closure가 가져가도록 할 수 있습니다.

     fn main() {
         let list = vec![1, 2, 3];
         println!("{:?}", list);
    
         thread::spawn(move || {
             println!("{:?}", list);
         }).join().unwrap();
     }
    

    이 방법은 closure를 새 쓰레드에 넘겨줌으로써, 데이터를 새 쓰레드로 이동시킬 때 유용합니다. 만약 메인 쓰레드가 소유권을 가지고 있는 상태에서 새 쓰레드가 데이터에 접근하려고 하는데, 이미 메인 쓰레드가 종료된 상태라면 데이터에 접근할 수 없게 되기 때문에, 새 쓰레드에서 데이터를 핸들링한다면 move 키워드를 사용하여 소유권을 넘겨주는 것이 좋습니다.

    move 키워드를 명시하지 않아도, closure의 body가 소유권을 필요로 하는 작업을 수행(예: drop)할 때 Rust는 Closure가 자동으로 소유권을 가져가도록 합니다.

위와 같이, Closure의 body가 capture한 값으로 어떤 작업을 수행하는지에 따라 세 가지 중 하나가 선택됩니다.

Closure가 갖는 Trait

Closure는 값을 다루는 방법에 따라 세 가지 trait를 자동으로 구현합니다.

  • FnOnce: 1회 호출 가능한 Closure

참고로 모든 Closure는 FnOnce를 구현합니다. 그러나 FnOnce를 구현한 Closure는 FnMutFn를 구현하지 않을 수도 있습니다. 또한 일반적인 함수도 FnOnce를 구현하고 있기 때문에, FnOnce를 구현한 타입을 요구하는 함수에 Closure 대신 일반 함수를 넘겨줄 수도 있습니다. 다른 Fn trait는 구현하지 않고 오직 FnOnce만 구현된 Closure는 보통 Environment Capture 시 값의 소유권을 가져가서 closure 밖으로 내보내는 경우입니다.

  • FnMut: Closure가 값을 변경할 수 있는 경우, 그리고 값이 closure 밖으로 나가지 않는 경우

이 trait을 구현하는 closure는 값의 소유권을 가져가지 않기 때문에, 여러 번 호출 가능합니다.

물론, 값을 변경하지 않는 closure 역시도 FnMut를 구현할 수 있으며, 일반적으로 그 경우를 의도하는 함수도 FnMut trait의 구현만을 요구할 수 있습니다. 함수와 직접적인 관련이 없는 외부 변수를 변경하는 closure 역시도 허용하기 위해서입니다.

  • Fn: Closure가 capture한 값을 변경하지 않고, 값이 closure 밖으로 나가지도 않거나, 또는 아예 environment의 값을 capture하지 않는 경우

이 trait을 구현하는 closure는 소유권을 가져가지 않기 때문에 여러 번 호출 가능하며, 환경의 불변성을 보장하기 때문에 동시에 closure를 여러번 호출하는 경우에도 안전합니다.

함수 역시도 Fn, FnMut, FnOnce를 구현하고 있기 때문에, 꼭 Environment Capture가 필요하지 않은 경우 closure 대신 함수 이름을 넘겨줄 수도 있습니다. 예를 들어 Option<Vec<T>> 값에 대해 unwrap_or_else(Vec::new) 메소드를 호출함으로써 Option 값이 None일 때 Vec::new()로 생성된 새로운 빈 벡터를 반환하도록 할 수 있습니다.

Iterators

Iterator는 두 가지 역할을 수행합니다: 연속된 값을 순회하고, 그 sequence가 종료되었는지 결정합니다. 따라서 iterator를 사용하면 이 logic을 다시 구현할 필요가 없습니다.

모든 iterator는 Iterator trait을 구현합니다. 그리고 이 trait과 연관된 타입(associate type)인 Item은 iterator가 반환하는 값의 타입을 나타냅니다. Iterator trait은 next() 메소드를 구현해야 하며, 이 메소드는 Option<Item>을 반환합니다. Some(Item)은 다음 값을 가리키고, None은 더 이상 값이 없음을 의미합니다.

pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// methods with default implementations elided
}

이 코드는 Iterator trait의 구현 시 Item 타입 역시도 정의해야 함을 의미합니다.

통상적으로 Iterator trait를 구현하는 오브젝트의 iterator는 .iter() 메소드로 구할 수 있습니다. 이러한 메소드는 개발자가 구현하지 않아도 default implementation이 존재합니다. 한편 Iterator Trait를 구현하려면 반드시 .next() 메소드를 구현해야 합니다. 이 메소드는 Option<T>를 반환하는데, Some(T)는 다음 값을 가리키고, None은 더 이상 값이 없음을 의미합니다.

Iterator trait을 구현하는 변수는 mut 키워드를 사용해야 함을 주의해야 합니다. 이는 iterator가 내부적으로 상태를 가지고 있으며, next() 메소드를 호출할 때마다 iterator의 상태가 변화하기 때문입니다. 그런데 for 문에서는 굳이 mut 키워드를 사용하지 않아도 됩니다. 이는 for loop가 iterator의 소유권을 가져가서 mutable로 만들기 때문입니다.

.collect() 메소드는 iterator를 소비하면서 iterator의 모든 값을 수집하여 새로운 Collection(Vec 등)을 만들어 반환합니다.

iter_mut() 메소드를 사용하여 iterator를 생성하면 iterator가 조회하는 Item을 mutable reference로 반환합니다.

한편 .into_iter() 메소드는 iter()메소드와 달리 원본 Collection 등의 소유권을 가져가는 메소드입니다. 다음과 같이 새로운 Collection을 만들어 반환하는 함수 등에 사용됩니다.

fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

만약 여기서 그냥 iter()메소드를 썼다면 벡터에 대한 immutable reference만 얻어지기 때문에 collect()로 생성되는 벡터는 Vec<&Shoe> 였을 것입니다.

Consuming adaptors

.sum() 메소드와 같은 일부 메소드는 iterator의 소유권을 가져가고 iterator를 소비합니다. 이러한 메소드를 consuming adaptors라고 부릅니다. .sum()의 경우는 내부적으로 .next()를 반복적으로 호출하면서 iterator를 소비하죠. 이러한 consuming adaptor를 사용한 후에는 동일한 iterator를 다시 사용할 수 없습니다.

Iterator Adaptors

.map() 메소드와 같은 일부 메소드는 iterator의 소유권을 가져가지 않고 또 다른 iterator를 새로 생성합니다. .map() 메소드는 argument로 closure를 받아 iterator의 각 요소에 closure를 적용한 새로운 iterator를 반환합니다. 이처럼 많은 iterator adaptor(<->consuming adaptor)들이 주로 argument로 closure를 받아 environment capture를 수행합니다.

또 다른 예로 .filter()는 closure를 받아 closure의 반환값이 true인 것만 새로 생성된 iterator의 값으로 포함합니다.

Loops vs. Iterators

Loop로 작성할 수 있는 함수를 대신 iterator가 갖고 있는 메소드를 활용해 Functional Programming Style로 더 빠르고 간결하게 작성 가능합니다. 예를 들어 .filter() 메소드는 for loop에서 요소 하나하나를 검사하는 것보다 훨씬 간결하게 같은 기능을 수행할 수 있죠. 직관적으로는 코드 실행도 더 low-level의 loop으로 구현되어 있을 Zero-cost abstraction의 .filter()메소드가 더 빠를 것이라고 예측도 가능합니다. 실제로 테스트해 보면 iterator를 사용한 코드가 더 빠르게 동작한다는 것을 알 수 있습니다.


참고 문헌

Rust 맛보기