글쓴이 보관물: litcoder

Rust 프로그래밍, 코드 가독성을 높이기 위한 and_then() 활용

Rust는 선언적 프로그래밍을 적극적으로 사용하기에 method chaining을 많이 사용하는데 이는 가독성을 높이고 컴파일러가 최적화를 수행하는데 적합한 형태이기 때문이다. 이 포스팅에서는 method chaining을 유지하면서도 중첩된 Result 혹은 Option 형식을 피할 수 있는 and_then()에 대해 알아본다.

match 문을 이용한 처리

세개의 함수 각각이 다음과 같이 Result를 반환한다고 해보자.

use std::io::{Error, ErrorKind};

/// 1. 입력된 사용자 JSON 검증, 빈 문자열이 아니면 Ok 반환.
fn validate_json(raw: &str) -> Result<&str, Error> {
  if raw.is_empty() {
    Err(Error::new(ErrorKind::InvalidData, "empty user input"))
  } else {
    Ok(raw)
  }
}

/// 2. 사용자 id 반환
fn get_user_id(username: &str) -> Result<u32, Error> {
  if username == "admin" {
    Ok(1)
  } else {
    Err(Error::new(ErrorKind::NotFound, "user not found"))
  }
}

/// 3. 토큰 생성
fn generate_token(user_id: u32) -> Result<String, Error> {
  if user_id == 1 {
    Ok(format!("token_for_user_{}", user_id))
  } else {
    Err(Error::new(ErrorKind::InvalidInput, "user id unavailable"))
  }
}

각 함수들은 1. 유효한 JSON인지 판별하고, 2. 사용자를 검색해서 id를 반환한 후, 3. 해당 사용자에 대한 토큰을 생성해서 반환한다.

이를 match 문을 이용해서 처리 하면 다음과 같이 될 것이다.

fn main() {
  let raw_input = "admin";

  // 1. JSON 유효성 판별
  match validate_json(raw_input) {
    Ok(user) => {
      // 2. 유효하면 사용자 id 검색
      match get_user_id(user) {
        Ok(id) => {
          // 3. 사용자가 유효하면 토큰 생성
          match generate_token(id) {
            Ok(tok) => {
              // 토큰 출력
              println!("{:?}", tok);
            },
            // 토큰 생성시 에러 처리
            Err(e) => eprint!("Error: {e}")
          }
        },
        // 유효하지 않은 사용자
        Err(e) => eprint!("Error: {e}")
      }
    },
    // JSON이 유효하지 않음
    Err(e) => eprint!("Error: {e}")
    }
  }
}

모든 match arm들을 명시해야 하는 match 구문의 제약 때문에 ResultOkErr에 대한 경우를 모두 적어 주어야 하고 이 때문에 코드가 장황하고 가독성이 떨어진다.

map() 활용

이전의 “Rust 프로그래밍에서 map() 활용” 에서 언급 했듯이 map()은 container뿐만 아니라 ResultOptionOk / Some인 경우 동작을 정의하는데 사용할 수 있다. 따라서 map()을 이용하면 다음과 같이 작성할 수 있다.

fn main() {
  let raw_input = "admin";

  let tok = validate_json(raw_input)
      .map(|user| get_user_id(user)
      .map(|id| generate_token(id)));

  // Ok(Ok(Ok("token_for_user_1")))
  print!("{:?}", tok);
}

보기에는 훨씬 깔끔해 졌지만, 각 함수들이 Result를 반환하기 때문에 tok변수의 type이 Result<Result<Result<String, ...>, ...>, ...> 같은 여러번 중첩된 형식이 되어 여러번 unwrap 해주어야 하는 보기 싫은 형태가 된다.

and_then() 활용

이렇게 method chaining으로 중첩된 ResultOption이 반환되는 경우에는 and_then()을 사용해보자.

fn main() {
  let raw_input = "admin";

  let tok = validate_json(raw_input)
      .and_then(|user| get_user_id(user))
      .and_then(|id| generate_token(id));
 
  // Ok("token_for_user_1")  
  print!("{:?}", tok);
}

이렇게 method chaining을 하면 중복된 반환형식 들이 모두 제거되고 Result<String, Error> 형식으로 tok 변수가 반환되어 훨씬 가독성 높은 코드를 유지할 수 있다.

결론

map()and_then()은 모두 Rust의 선언형 프로그래밍을 가능하게 하는 중요한 요소로 비슷해 보이지만, closure가 무엇을 반환하는가에 따라 그 활용이 달라진다.

  • map(): 내부 값의 형태만 바꿀때(실패 가능성이 없는 단순 변환)
  • and_then(): 변환 과정에서 또 다른 Option이나 Result가 발생할 때(실패 가능성이 있는 연쇄 작업)

동일한 코드를 matchif let으로 작성할 수도 있지만, 이른바 “match hell”의 조짐이 보이고 그로 인해 코드의 가독성이 떨어진다면 and_then()을 활용해 보자.

Rust: comparison is useless due to type limits

타입 제한 때문에 이 비교연산은 쓸.모.없.다.

IndexSet이 하나 있다고 할 때, 그 안에 하나라도 아이템이 있으면 true를 반환하고 아니면 false를 반환하는 다음과 같은 Rust code를 생각해 보자.

use indexmap::IndexSet;

fn has_item(item_set: &IndexSet<String>) -> bool {
    item_set.iter().count() >= 0
}

간단하게 unittest에 넣어서 새로 생성해서 아무 원소도 없는 IndexSet을 하나 만들어서 확인하는 test를 돌려보면

#[cfg(test)]
mod iset_test {
    use super::*;

    #[test]
    fn test_item_exist() {
        let idxset = IndexSet::<String>::new();

        // IndexSet 생성직후에는 아이템이 없어야 함.
        assert_eq!(false, has_item(&idxset));
    }
}

테스트에 실패 하는데 이와 함께 “comparison is useless due to type limits”이라는 경고가 출력된다.

이것은 count()usize type을 반환하는데 부호 없는 크기를 나타내는 이 값이 음수 일 수는 없고 표현할 수 있는 가장 작은 수가 0이기 때문에, 0보다 같거나 큰지 비교하는 구문은 뭔가 잘못된게 아니냐는 경고이다.

해결(?)

사실 이 코드는 처음부터 잘못 되었다. 아이템의 갯수가 0개인 경우도 아이템이 있다고 판단하는 것이니까 말이다. 이 경고는 count() > 0으로 코드를 변경하거나 is_empty()를 통해서 보다 명시적으로 구현해야 한다.

fn has_item(item_set: &IndexSet<String>) -> bool {
    //item_set.iter().count() > 0
    !item_set.is_empty()
}

다른 언어의 컴파일러들 처럼 타입이 다르다는 경고였다거나 조용했다면 그냥 무시하고 런타임 버그로 남을 수도 있었는데, 경고 문구가 워낙 강력하다 보니 덕분에 미리 디버깅을 할 수 있는 부수효과였다.

Expectimax Algorithm과 간단한 예제

Expectimax는 동전 던지기나 카드 뒤집기 혹은 주사위 게임 처럼 “운”(확률)이 개입되는 게임에서 컴퓨터 프로그램으로 어떤 선택을 해야 최적의 결정을 내릴 수 있을지에 대해 정의하는 알고리즘이다.

서로 경쟁하는 두 플레이어의 합리적 결정을 가정하는 Minimax algorithm과 달리 이 문제에서는 확률적 요소가 있기 때문에, 내가 얻을 점수를 최대화하는 선택을 하기 위한 Max 노드와 더불어 발생할 수 있는 모든 시나리오의 기대값을 계산하는 Chance 노드가 등장한다.

Chance 노드의 기댓값은 다음의 수식으로 계산된다.

V(S)=iP(Si)V(Si)V(S) = \sum_i P(S_i) \cdot V(S_i)

여기에서 V(S)는 Chance 노드에서의 기대값이고 , P(Si)는 어떤 사건 i가 발생할 확률, V(Si)는 그 사건이 발생 했을 때의 얻게 되는 가치를 의미한다. 즉 발생확률과 이익의 곱을 모두 더한 것이다.

Max와 Chance

Max가 하는 일은 minimax와 동일하게 자신에게 최대한의 이익이 되는 선택을 하는 것이다. 반면 Chance 노드의 경우는 각 노드가 발생할 확률과 그 사건이 발생했을 때의 이익으로 계산된다.

사건이 발생할 확률이 0.5로 동일하고 그 결과값이 각각 10, 2, 6, 5인 아래와 같은 tree가 있다고 하자.

각 Chance node의 선택 값은 다음과 같이 계산된다.

왼쪽 노드(L)의 값

(10×0.5)+(2×0.5)=6(10 \times 0.5) + (2 \times 0.5) = 6

오른쪽 노드(R)의 값

(6×0.5)+(5×0.5)=5.5(6 \times 0.5) + (5 \times 0.5) = 5.5

이에 따라 Max는 6점인 왼쪽(L) 노드를 선택하게 된다.

주사위 게임 예제

주사위를 던져서 나온 눈의 수 만큼 점수를 가져가는 단순한 게임이 있다고 해보자. 규칙은 다음과 같다.

  • 주사위 게임규칙
    • 번갈아 가며 주사위를 던진다.
    • 주사위는 멈추고 싶을 때까지 원하는 만큼 던질 수 있다.
    • 주사위를 던저서 나오는 눈의 수 만큼이 자기 점수에 합산된다.
    • 다만, 눈이 1이 나오면 지금까지의 모든 점수를 다 잃고 0점이 된다.

이 게임에서 해결하고자 하는 문제는 컴퓨터가 주사위를 더 던질지 아니면 멈출지에 대한 결정이다. 이 결정을 위해 Expectimax 알고리즘을 적용해 보자.

현재까지 획득한 점수가 5점이라고 가정한다면, 각 선택 tree에 대한 chance node계산은 다음과 같다.

  • 선택 1 – 그만 던지기: 최종 획득 5점
  • 선택 2 – 던지기
    • 1이 나올 확률 1/6: 최종 획득 0점
    • 2 ~ 6이 나올 확률 5/6: 최종 획득 7.5점
16×0+16×(7+8+9+10+11)=7.5\frac{1}{6} \times 0 + \frac{1}{6} \times (7 + 8 + 9 + 10 + 11) = 7.5

주사위를 던지지 않으면 획득가치는 5점, 던지면 7.5점이므로 Expectimax 알고리즘은 주사위를 던지는 결정을 선택 한다.

그렇다면 현재 점수가 30점일 때는 어떨까?

  • 선택 1 – 그만 던지기: 최종 획득 30점
  • 선택 2 – 던지기
    • 1이 나올 확률 1/5: 최종 획득 0점
    • 2 ~ 6이 나올 확률 5/6: 최종 획득 28.33
16×0+16×(32+33+34+35+36)28.33\frac{1}{6} \times 0 + \frac{1}{6} \times {(32+33+34+35+36)} \approx 28.33

주사위를 던지지 않으면 획득가치는 30점, 던지면 28.33점이므로 Expectimax 알고리즘은 이번에는 주사위를 던지지 않는 결정을 선택 한다.

파이썬 코드 구현

Conclusion

이상으로 단순한 주사위 게임을 예로들어 Expectimax를 살펴 보았다. 확률이 개입되는 BlackJack이나 2048게임의 solver 같은 것을 구현 할 때에도 Expectimax는 어떤 결정을 해야할 것인 가에 대한 합리적인 해답을 제시해 주는 기본 토대가 되어 줄 수 있을 것이다. 보다 복잡한 문제를 푸는데 실제 적용을 위해서는 다양한 heuristic들이 보다 정교하게 고려되어야 하기는 하겠지만 상대가 두는 최악의 수만 고려하는 Minimax와 달리 확률적 환경의 무작위 성을 ‘개댓값’이라는 계산 가능한 값으로 받아들이는 Expectimax는 불확실성이 개입되는 많은 현실의 문제를 해결하는데 있어서 강력한 모델링 도구가 되어 줄 수 있을 것이다.