비욘드 JS: 러스트 - Closure

dante Yoon·2023년 1월 1일
0

beyond js

목록 보기
13/20
post-thumbnail

글을 시작하며

안녕하세요, 단테입니다.
러스트는 함수형 프로그래밍을 작성하기 용이하게 구현된 프로그래밍 언어입니다.
해당 기법을 사용하기 용이하게 해주는 Closure에 대해 알아보겠습니다.

Closures

러스트에서 클로저는 변수에 저장하거나 다른 함수에 인수로 전달할 수 있는 익명 함수입니다. 클로저는 한번 생성되면 다른 콘텍스트에 의해 평가되어 사용할 수 있습니다. 함수와 다르게 클로저는 정의한 곳에 있던 스코프의 값들을 저장할 수 있습니다.

Capturing the Environment with Closures

먼저 클로저가 주변 환경의 값들을 어떻게 저장하고 있다가 나중에 사용하는지 살펴보겠습니다.
티셔츠 회사에서 리미티드 에디션 한정판 제을 메일링 리스트에있는 고객들에게 선물한다고 가정합시다. 메일링 리스트에 있는 사람들은 선택적으로 프로필에 가장 좋아하는 색을 설정할 수 있는데 선물로 보낼 티셔츠 모델이 고객이 설정한 색상을 제공할 수있는 제품이면 자동으로 해당 색상의 티셔츠가 제공됩니다.
없으면 무작위의 색상 티셔츠가 선택되어 제공됩니다.

이를 구현하기 위해서는 Red, Blue variants가 있는 ShirtColor라는 enum값을 사용할 것입니다.

https://unsplash.com/s/photos/blue-red-t-shirt

회사의 제고를 나타내기 위해 shirts를 필드로 가지고 있는 Invetory 스트럭트를 사용하겠습니다.

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

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
        }
    }
}

giveaway 메소드는 Inventory 에 고객 프로필 설정을 받고 이에 따른 발송할 티셔츠를 반환합니다.

메인 함수에 정의된 store는 두 개의 blue, 하나의 red 컬러의 티셔츠를 가지고 있습니다.
red 색상을 선호하는 user_pref1에게 발송할 티셔츠 색상을 giveaway1에 저장했습니다.

여기서 giveaway 메소드를 보면 closure가 사용된 것을 볼 수 있습니다. 바로 || self.most_stocked()입니다. giveaway 내부에서는 표준 라이브러리 unwrap_or_else 메소드를 사용합니다. Option<ShirtColor> 타입을 반환하는 클로저를 파라메터로 받고 있습니다.
|| self.most_stocked() 클로저는 아무런 파라메터를 받지 않지만 만약 클로저가 호출될 때 파라메터가 필요하다면 ||(vertical bars) 사이에 해당 arguement를 넣을 수 있습니다. 클로저 바디에서는 self.most_stocked()를 호출하고 있는데요 나중에 unwrap_or_else가 평가될 때 클로저가 사용됩니다.

cargo run
   Compiling shirt-company v0.1.0 (file:///projects/shirt-company)
    Finished dev [unoptimized + debuginfo] target(s) in 0.27s
     Running `target/debug/shirt-company`
The user with preference Some(Red) gets Red
The user with preference None gets Blue
impl Inventory {
    fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
        user_preference.unwrap_or_else(|| self.most_stocked())
    }

표준 라이브러리를 호출할 때 Inventory 인스턴스에서 self.most_stocked()를 호출하는 클로저를 전달했습니다. 표준 라이브러리 unwrap_or_else는 Inventory나 ShirtColor 타입에 대해서는 아는 정보가 전혀 없습니다. 클로저는 Inventory 인스턴스에 대한 immutable한 레퍼런스를 캡쳐하고 unwrap_or_else 메소드의 파라메터로 넘겨집니다. 함수는 이러한 동작을 수행할 수 없습니다.

Closure Type Inference and Annotation

함수의 경우 유저에게 노출되는 인터페이스의 일부이기 때문에 타입을 명시해야 합니다. 클로저는 인터페이스의 일부가 아니고 변수를 저장하고 있다가 코드를 사용하는 부분에서 노출하기 때문에 함수와 다르게 보통 타입 어노테이션을 요구하지 않습니다.

클로저는 작은 범위의 콘텍스트에서 사용되기 때문에 타입 정의를 하지 않아도 컴파일러에 의해 타입 추론이 됩니다. 간혹 변수와 같이 특정한 경우에 명시적으로 타입 선언을 해야 한다면 아래와 같이 작성할 수 있습니다.

  let expensive_closure = |num: u32| -> u32 {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
  };

타입 어노테이션을 사용할 때 클로저의 문법은 함수와 비슷합니다. 아래 코드를 통해 파라메터에 1을 추가하는 함수와 클로저의 문법이 어떻게 유사하고 다른지 알 수 있습니다.

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

add_one_v2는 모든 타입 어노테이션을 추가했으므로 fully annotated closure라고 부릅니다. add_one_v3는 클로전 정의에 타입 어노테이션을 삭제했습니다. add_one_v4는 클로저에 한 개의 expression 밖에 없으므로 중괄호를 생략했습니다.

정의된 클로저에 대해 컴파일러는 파라메터와 반환 값에 대한 추론을 수행합니다.

let example_closure = |x| x;

let s = example_closure(String::from("hello"));
let n = example_closure(5);

위 예제에서 example_closure는 파라메터를 그대로 반환하고 있습니다. 타입 정의가 없으므로 어떤 타입이든 넘길 수 있습니다. 하지만 example_closure를 호출할 때 에러가 발생합니다.

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |                             ^- help: try using a conversion method: `.to_string()`
  |                             |
  |                             expected struct `String`, found integer

For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure-example` due to previous error

처음 호출할 때 String 값을 넣었기 때문에 컴파일러는 클로저의 x를 String으로 생각하나 다음에는 integer를 넣었기 때문에 에러가 발생하는 것입니다.

에러를 수정하기 위해 명시적으로 타입 어노테이션을 사용할 수 있습니다.

let example_closure = |x: String| x;

let s = example_closure(String::from("hello"));

let example_closure = |x: i32| x;

let n = example_closure(5);

다른 방법으로는 변수의 타입을 명시할 수 있습니다.

let example_closure = |x| x;

let s: String = example_closure(String::from("hello"));

let n: i32 = example_closure(5);

Capturing References or Moving Ownership

클로저가 값을 기억할 수 있는 방법은 세가지가 있습니다. 이 세가지는 함수가 파라메터를 받아들이는 방법들과 일대일로 매칭됩니다. 함수 바디가 어떻게 값을 사용하는지에 따라 이 방법 중 한가지로 결정됩니다.

borrowing immutably

list라는 이름의 벡터 레퍼런스를 클로저에서 기억하는 예제 코드입니다.

변수는 클로저의 정의부와 바인딩 될 수 있고 호출할 수 있습니다.

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {:?}", list);

    let only_borrows = || println!("From closure: {:?}", list);

    println!("Before calling closure: {:?}", list);
    only_borrows();
    println!("After calling closure: {:?}", list);
}

우리는 list에 대한 immutable reference를 여러 개 가질 수 있습니다. 클로저 정의하기 전에, 클로저 정의한 후에 클로저를 호출하기 전에, 클로저를 호출한 후에.

$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]

borrowing mutably

아래 코드에서 클로저는 mutable reference를 기억하고 있습니다.
클로저 정의부와 클로저 호출부 사이에 immutable borrow는 허용되지 않기 때문에 중간에 있던 프린트구문은 삭제를 했습니다.

fn main() {
    let mut list = vec![1, 2, 3];
    println!("Before defining closure: {:?}", list);

    let mut borrows_mutably = || list.push(7);

    borrows_mutably();
    println!("After calling closure: {:?}", list);
}
$ cargo run
   Compiling closure-example v0.1.0 (file:///projects/closure-example)
    Finished dev [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/closure-example`
Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]

taking ownership

만약 값에 대한 오너쉽을 클로저가 갖게 하기 위해서 우리는 move 키워드를 파라메터 리스트 전에 사용할 수 있습니다.
이 방법은 클로저를 새로운 스레드로 넘겨 스레드에서 데이터에 대한 오너쉽을 갖게 할 때 유용합니다. 아래 코드에서 스레드를 새로 만든 후 클로저 선언부에 move keyword를 작성해 메인 스레드가 아닌 새로운 스레드에 클로저를 넘겼습니다. 메인 스레드에서 list에 대한 오너쉽을 가지고 있는 중 list를 드롭해버린다면 immutable reference가 invalid하기 때문에 컴파일러는 새로운 스레드에 넘기는 list를 move 해야한다고 말합니다.

use std::thread;

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {:?}", list);

    thread::spawn(move || println!("From thread: {:?}", list))
        .join()
        .unwrap();
}

Moving Captured Values Out of Closures and the Fn Traits

클로저가 클로저 정의부에 선언되어있는 어떤 레퍼런스나 특정 값에 대한 오너쉽을 가지고 있다면 (이것을 클로저에 moved되었다고 합니다.) 당연한 이야기지만 클로저 바디에 있는 코드가 나중에 호출될 때 해당 레퍼런스나 값에 어떤 일을 할지 결정하게 됩니다.

  • 클로저가 가져온 값을 외부로 move하거나
  • 가져온 값을 변경하거나
  • 환경에서 아무런 값도 가져오지 않거나

클로저가 값을 캡처하거나 값을 다루는 방식에 따라 클로저가 구현하는 trait이 달라집니다. 여기서 trait은 함수나 struct가 어떤 클로저를 사용할 수 있는지 지정하는 방법입니다. 클로저가 바디에서 값을 어떻게 다루는지에 따라 다음 세가지 중의 하나의 Fn trait을 구현하게 됩니다.

FnOnce

클로저는 항상 어디선가 호출될 수 있기 때문에 적어도 이 trait은 모든 클로저에서 기본적으로 구현됩니다. 값을 클로저 바디 외부로 move 하는 클로저는 FnOnce만 구현하고 다른 Fn trait은 구현하지 않습니다. 왜냐하면 단 한번만 호출할 수 있기 때문입니다.

FnMut

값을 바디 외부로 보내지 않는 클로저가 구현하는 trait입니다. 캡쳐된 값을 변경할 수 있고 한 번 이상 호출될 수 있습니다.

Fn

클로저 바디 외부로 값을 이동시키지 않고 값을 변경하지 않는 클로저, 클로저 정의부에 있는 어떤 값도 캡쳐하지 않는경우 Fn trait을 구현합니다. 이 클로저는 환경을 변경시키지 않아 동시다발적으로 호출되어도 사이드이펙트를 발생시키지 않습니다.

예제 1

Option<T>의 unwrap_or_else의 정의에 대해 살펴봅시다.

impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

T는 Option의 Some variant에서 사용되는 타입에 대한 제너릭 타입이며 unwrap_or_else 함수의 반환 타입입니다. Option<String>에서 호출하는 unwrap_or_else의 반환 타입은 String이 됩니다.
제너릭이 특정 trait을 구현해야 하는 것을 나타내는 것을 trait bound라고 합니다.
제너릭 F는 FnOnce() -> T trait bound를 가지고 있습니다. F는 한번 이상 호출되어야 하고 어떤 argument도 받아들이지 않는다는 것, T를 반환해야 한다는 것을 나타냅니다.

F는 parameter f의 타입을 나타내며 따라서 unwrap_or_else는 적어도 한번 f를 호출해야 한다는 것을 나타내며 단 한번만 호출할 수 있다는 것을 나타냅니다.
Option이 Some이면 f는 호출되지 않을 것입니다. Option이 None이면 f는 한번만 호출될 것입니다.
모든 클로저들은 FnOnce를 구현하기 때문에 unwrap_or_else는 모든 종류의 클로저를 받아들일 수 있습니다.

예제 2

FnOnce말고 FnMut를 trait bound로 사용하는 경우를 살펴보기 위해
slice에 사용하는 sort_by_key 표준라이브러리 메소드를 예시로 들겠습니다.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    list.sort_by_key(|r| r.width);
    println!("{:#?}", list);
}

이 예제에서 Rectangle 인스턴스의 리스트를 가지고 있다가 sort_by_key를 이용해 width 속성으로 오름차순 정렬하는 예제입니다.
클로저는 하나의 슬라이스의 현재 아이템에 대한 참조를 가져오고 정렬될 수 있는 유형의 값을 반환합니다.
아이템의 특정 속성으로 slice를 정렬하고 싶을 때 유용합니다.

$ cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
    Finished dev [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/rectangles`
[
    Rectangle {
        width: 3,
        height: 5,
    },
    Rectangle {
        width: 7,
        height: 12,
    },
    Rectangle {
        width: 10,
        height: 1,
    },
]

sort_by_key가 FnMut 클로저를 받아들이는 이유는 클로저를 여러번에 걸쳐 호출하기 때문입니다.
여기서 클로저는 |r| r.width 인데 정의부의 환경에 있는 어떠한 값도 캡쳐하거나 mutate하거나 move하지 않습니다.

아래 예제코드와 같이 FnOnce trait을 구현하는 클로저의 경우는 sort_by_key의 FuMut 클로저와 위반되기 때문에 컴파일 에러가 발생합니다.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut sort_operations = vec![];
    let value = String::from("by key called");

    list.sort_by_key(|r| {
        sort_operations.push(value);
        r.width
    });
    println!("{:#?}", list);
}

아래에서 에러는 클로저 구현부인 바디를 가르키고 있습니다. 이 바디는 환경에 선언된 value를 move하고 있습니다. 이 에러를 수정하기 위해서 value move하는 부분을 변경해야 합니다.

cargo run
   Compiling rectangles v0.1.0 (file:///projects/rectangles)
error[E0507]: cannot move out of `value`, a captured variable in an `FnMut` closure
  --> src/main.rs:18:30
   |
15 |       let value = String::from("by key called");
   |           ----- captured outer variable
16 | 
17 |       list.sort_by_key(|r| {
   |  ______________________-
18 | |         sort_operations.push(value);
   | |                              ^^^^^ move occurs because `value` has type `String`, which does not implement the `Copy` trait
19 | |         r.width
20 | |     });
   | |_____- captured by this `FnMut` closure

For more information about this error, try `rustc --explain E0507`.
error: could not compile `rectangles` due to previous error

다음 코드는 위의 예제를 mutable reference를 캡처함으로 수정했습니다.

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut num_sort_operations = 0;
    list.sort_by_key(|r| {
        num_sort_operations += 1;
        r.width
    });
    println!("{:#?}, sorted in {num_sort_operations} operations", list);
}

글을 마무리하며

난이도가 꽤 있었던 챕터 같습니다.
끝까지 읽어주셔서 감사합니다 :)
화이팅!

profile
성장을 향한 작은 몸부림의 흔적들

0개의 댓글