#19 클로저

Pt J·2020년 9월 3일
1

[完] Rust Programming

목록 보기
22/41
post-thumbnail

이 시리즈는 Rust 공식문서를 통해 공부한 흔적임을 밝힙니다.

Rust는 다른 여러 언어들의 영향을 받고 만들어졌다.
Rust에 영향을 준 것들 중 하나가 함수형 프로그래밍이다.
함수형 프로그래밍에서는 함수를 다른 함수의 인자로 전달하거나 함수를 반환 받거나
함수를 변수에 대입하기도 한다.

우리가 앞서 살펴보았던 열거형이나 패턴 매칭도
함수형 프로그래밍으로부터 비롯된 특징이다.
이번 시간에는 그런 것들 중 하나인 클로저에 대해 알아보도록 하겠다.

클로저 Closure

클로저는 변수에 저장하거나 다른 함수에 인자로 전달할 수 있는 익명 함수다.
변수처럼 사용하고 전달할 수 있기에 한 곳에서 생성된 클로저를 다른 문맥에서 호출할 수 있다.

클로저를 사용하면 유용한 상황을 알아보기 위해 하나의 시나리오를 생각해보겠다.
우리가 작성하는 어떤 프로그램에 가상의 오래 걸리는 함수가 존재한다고 하자.
이 녀석이 오래 걸리도록 하기 위해 우리는 이 함수로 하여금
수행 중을 띄우며 2초 동안 정지했다가 반환값을 내보내도록 할 것이다.

우리가 작성해볼 간단한 프로그램은 맞춤형 운동 계획을 세워주는 어플로,
운동 강도와 임의의 숫자를 통해 적절한 운동 계획을 제시해준다.
그리고 이 적절한 운동 계획을 계산해주는 과정에서
앞서 언급된 매우 오래 걸리는 함수가 사용된다.

임의로 사용자의 운동 강도 및 임의의 숫자를 하드코딩하고
우선 클로저를 사용하지 않는 방식으로 코드를 작성해보자.

peter@hp-laptop:~/rust-practice$ mkdir chapter13
peter@hp-laptop:~/rust-practice$ cd chapter13
peter@hp-laptop:~/rust-practice/chapter13$ cargo new workout_plan
     Created binary (application) `workout_plan` package
peter@hp-laptop:~/rust-practice/chapter13$ cd workout_plan/
peter@hp-laptop:~/rust-practice/chapter13/workout_plan$ vi src/main.rs

src/main.rs

use std::thread;
use std::time::Duration;

fn simulated_expensive_calculation(intensity: u32) -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    intensity
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(
        simulated_user_specified_value,
        simulated_random_number
    );
}

fn generate_workout(intensity: u32, random_number: u32) {
    if intensity < 25 {
        println!("Today, do {} pushups!",
            simulated_expensive_calculation(intensity)
        );
        println!("Next, do {} situps!",
            simulated_expensive_calculation(intensity)
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!("Today, run for {} minutes!",
                simulated_expensive_calculation(intensity)
            );
        }
    }
}

이 프로그램은 사용자의 운동 강도가 25 이상인지 여부에 따라 다른 계획을 세워주고
운동 강도가 높을 때 임의의 숫자가 3으로 설정될 경우 하루는 쉬고 가라고 한다.

그런데 여기서 우리는 simulated_expensive_calculation 함수를 여러 곳에서 호출한다.
로직에 따라 이 녀석을 최대 두 번 호출한다.
따라서 우린 우리 프로그램이 이 녀석을 반복 호출하는 일이 없도록 리팩토링을 한다.
함수의 중복 호출을 방지하기 위해 그것을 단 한 번 호출하고
함수 호출 결과를 변수에 저장하여 사용하는 방식으로 변경해보자.

peter@hp-laptop:~/rust-practice/chapter13/workout_plan$ vi src/main.rs

src/main.rs

use std::thread;
use std::time::Duration;

fn simulated_expensive_calculation(intensity: u32) -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    intensity
}

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(
        simulated_user_specified_value,
        simulated_random_number
    );
}

fn generate_workout(intensity: u32, random_number: u32) {
    let expensive_result = simulated_expensive_calculation(intensity);
    if intensity < 25 {
        println!("Today, do {} pushups!",
            expensive_result
        );
        println!("Next, do {} situps!",
            expensive_result
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!("Today, run for {} minutes!",
                expensive_result
            );
        }
    }
}

그런데 이렇게 수정하고 나니 또다른 문제가 생겼다.
최대 호출 횟수를 줄임으로써 최대 시간을 줄였지만
원래 이 녀석을 호출할 필요가 없던 분기문까지 이것을 호출하고 변수에 저장하여
최소 시간은 증가하였다는 점이다.

함수 호출 코드를 한 군데에서만 작성하면서도 필요할 때만 호출하는 방법은 업을까?
이런 때 사용할 수 있는 게 클로저다.

클로저의 특징

클로저는 파이프 문자 | 쌍과 중괄호 블록으로 이루어져 있다.
파이프 문자 쌍 사이에는 이 클로저에서 사용될 매개변수가 들어갈 수 있고
중괄호 블록은 하나의 표현식으로 이루어져 있을 경우 중괄호를 생략할 수 있다.
이것은 이름을 갖지 않는 익명 함수이며 어떤 변수에 저장해놓고 변수를 통해 호출한다.

클로저는 좁은 문맥 안에서 실행되어 그 매개변수와 반환값의 자료형을 안정적으로 유추할 수 있다.
따라서 대부분의 경우 클로저의 자료형은 생략할 수 있다.
물론 함수에서처럼 :->를 통해 자료형을 명시할 수도 있다.
자료형 애노테이션을 생략한 클로저는 어떤 자료형이든 가질 수 있지만
한 번 클로저를 호출하고나면 Rust 컴파일러는 클로저의 자료형을 그것으로 유추하여
다음 호출에서도 같은 자료형을 사용하지 않으면 오류가 발생한다.

peter@hp-laptop:~/rust-practice/chapter13/workout_plan$ vi src/main.rs

src/main.rs

use std::thread;
use std::time::Duration;

/*
fn simulated_expensive_calculation(intensity: u32) -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    intensity
}
*/

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(
        simulated_user_specified_value,
        simulated_random_number
    );
}

fn generate_workout(intensity: u32, random_number: u32) {
    //let expensive_result = simulated_expensive_calculation(intensity);
    let expensive_closure = |num| {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    };
    if intensity < 25 {
        println!("Today, do {} pushups!",
        //    expensive_result
            expensive_closure(intensity)
        );
        println!("Next, do {} situps!",
        //    expensive_result
            expensive_closure(intensity)
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!("Today, run for {} minutes!",
            //    expensive_result
            expensive_closure(intensity)
            );
        }
    }
}

그런데 클로저를 사용하여 작성하고 보니 처음에 직면했던 문제로 다시 돌아왔다.
하지만 이것은 클로저의 특징을 통해 해결할 수 있다.

지연 평가 Lazy Evaluation

클로저는 변수에 저장할 수 있다고 했다.
따라서 우리는 클로저와 그 결과값을 어떤 구조체에 저장해놓고 사용할 수 있다.
이를 통해 최초로 한 번 클로저를 호출하여 값을 구한 후 그것을 결과값 변수에 저장해놓고
그 이후로는 결과값 변수를 참조하는 방식이다.
이러한 방식을 지연 평가Lazy Evaluation 또는 메모이제이션Memoization이라고 한다.

모든 클로저는 표준 라이브러리의 Fn, FnMut, FnOnce 중 적어도 하나의 트레이트를 구현한다.
우리는 지연 평가를 위한 구조체에서 클로저를 제네릭으로 선언하고
이 트레이트를 통해 그것의 시그니처를 설정할 수 있다.

peter@hp-laptop:~/rust-practice/chapter13/workout_plan$ vi src/main.rs

src/main.rs

use std::thread;
use std::time::Duration;

struct Cacher<T>
    where T: Fn(u32) -> u32
{
    calculation: T,
    value: Option<u32>,
}

impl<T> Cacher<T>
    where T: Fn(u32) -> u32
{
    fn new(calculation: T) -> Cacher<T> {
        Cacher {
            calculation,
            value: None,
        }
    }
  
    fn value(&mut self, arg: u32) -> u32 {
        match self.value {
            Some(v) => v,
            None => {
                let v = (self.calculation)(arg);
                self.value = Some(v);
                v
            }
        }
    }
}

/*
fn simulated_expensive_calculation(intensity: u32) -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    intensity
}
*/

fn main() {
    let simulated_user_specified_value = 10;
    let simulated_random_number = 7;

    generate_workout(
        simulated_user_specified_value,
        simulated_random_number
    );
}

fn generate_workout(intensity: u32, random_number: u32) {
    //let expensive_result = simulated_expensive_calculation(intensity);
    let mut expensive_result = Cacher::new(|num| {
        println!("calculating slowly...");
        thread::sleep(Duration::from_secs(2));
        num
    });
    if intensity < 25 {
        println!("Today, do {} pushups!",
            expensive_result.value(intensity)
        );
        println!("Next, do {} situps!",
            expensive_result.value(intensity)
        );
    } else {
        if random_number == 3 {
            println!("Take a break today! Remember to stay hydrated!");
        } else {
            println!("Today, run for {} minutes!",
                expensive_result.value(intensity)
            );
        }
    }
}

지연 평가의 한계

우리 코드처럼 매번 지연 평가 때마다 같은 인자를 전달한 경우라면 상관 없지만
전달되는 인자가 달라질 경우 지연 평가는 문제가 될 수 있다.
지금 전달한 인자가 아닌 최초에 전달했던 인자에 대한 결과값으로 반환되는 것이다.
이것은 해시 맵을 통해 쉽게 해결할 수 있다.
결과값을 단일 변수가 아닌 해시 맵에 저장해놓고
해시 맵에서 그 인자가 발견되지 않은 경우에만 클로저를 호출하는 것이다.

그리고 클로저의 시그니처를 명시하여 다른 자료형을 사용할 수 없는데
Fn 트레이트에 전달되는 자료형을 제네릭으로 사용하면 지연 평가의 유연성을 높일 수 있다.

환경 캡처 Environment Capture

클로저에는 함수에 없는 특별한 기능이 존재한다.
자신이 정의된 범위 내의 값을 캡처해서 접근할 수 있다는 것이다.
다음과 같은 코드가 가능하다는 것이다.

peter@hp-laptop:~/rust-practice/chapter13/workout_plan$ cd ..
peter@hp-laptop:~/rust-practice/chapter13$ cargo new env_capture
     Created binary (application) `env_capture` package
peter@hp-laptop:~/rust-practice/chapter13$ cd env_capture/
peter@hp-laptop:~/rust-practice/chapter13/env_capture$ vi src/main.rs

src/main.rs

fn main() {
    let x = 4;

    let equal_to_x = |z| z == x;

    let y = 4;

    assert!(equal_to_x(y));
}

만약 이것이 함수였다면 우리는 x에 접근할 수 없었겠지만 클로저이기 때문에 가능한 일이다.
클로저가 주변 환경에 있는 변수를 캡처해서 가져 오는 방법은 세 가지가 있다.
소유권을 가져오거나, 불변으로 대여하거나, 가변으로 대여하는 것이다.
앞서 언급된 Fn과 같은 트레이트가 이를 명시한다.

  • FnOnce : 클로저를 선언하는 시점에 최초로 한 번 변수가 클로저 안으로 이동한다. 즉, 소유권이 이동한다.
  • FnMut : 주변 환경에 있는 변수를 가변으로 대여한다.
  • Fn : 주변 환경에 있는 변수를 불변으로 대여한다.

클로저가 어떤 트레이트를 구현할지는 주변 환경에서 가져온 변수에 대한 사용법을 통해
Rust 컴파일러가 자체적으로 유추한다.
클로저가 환경에서 가져온 값에 대한 소유권을 갖도록 하기 위해서는
파이프 문자 쌍 앞에 move 명령어를 사용할 수 있다.
클로저를 새로운 쓰레드에 전달하고 쓰레드에 데이터를 전달하여 소유하게 할 때 유용한데,
이후에 쓰레드에 대해 다룰 때 클로저를 많이 마주하게 될 것이다.

이 포스트의 내용은 공식문서의 13장 1절 Closures: Anonymous Functions that Can Capture Their Environment에 해당합니다.

profile
Peter J Online Space - since July 2020

0개의 댓글