Rustでチケット料金モデリングしてみる

Rustの勉強がてら以前話題になったチケット料金モデリングを実装してみました。

このチケット料金表を実装したのですが、すでにScalaで作られているこちらを参考にScalaからRustで作り変えていき、実装の違いのポイントを確認しながら進めてみました。

Rustで実装したものはこちらになります。

Specificationトレイト

条件を満たしているかどうかを判定するためのSpecificationトレイトは以下のようになりました。

pub trait Specification<T> {
    fn is_satisfied_by(&self, arg: &T) -> bool;
}

impl<T> std::fmt::Debug for dyn Specification<T> {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "")
    }
}

pub struct OrSpecification<T> {
    spec1: Box<dyn Specification<T>>,
    spec2: Box<dyn Specification<T>>,
}

impl<T> Specification<T> for OrSpecification<T> {
    fn is_satisfied_by(&self, arg: &T) -> bool {
        self.spec1.is_satisfied_by(arg) || self.spec2.is_satisfied_by(arg)
    }
}

pub struct AndSpecification<T> {
    pub spec1: Box<dyn Specification<T>>,
    pub spec2: Box<dyn Specification<T>>,
}

impl<T> Specification<T> for AndSpecification<T> {
    fn is_satisfied_by(&self, arg: &T) -> bool {
        self.spec1.is_satisfied_by(arg) && self.spec2.is_satisfied_by(arg)
    }
}

pub struct NotSpecification<T> {
    pub spec: Box<dyn Specification<T>>,
}

impl<T> Specification<T> for NotSpecification<T> {
    fn is_satisfied_by(&self, arg: &T) -> bool {
        !self.spec.is_satisfied_by(arg)
    }
}

Scalaでの実装の場合、AndやOr, Notの条件は通常のSpecificationトレイトを拡張することで型クラスの多相性を簡単に表現できます。Rustでも以下のようにAnd, Or, Notを多層的に表現しようと思ったのですが

pub enum Spec<T> {
    Normal(Specification<T>),
    And { spec1: Spec<T>, spec2: Spec<T> },
    Or { spec1: Spec<T>, spec2: Spec<T> },
    Not { spec: Spec<T> },
}

Normal(Specification<T>) にたいしてdoesn't have a size known at compile-timeと表示されるようで、Specificationにたいして何かトレイトを実装するなどすれば解決できるのかもしれないですが、今回はそのままAnd, Or, Notの構造体を用意し、Specificationトレイトを実装することで、それぞれの条件を同様に扱えるようにしておきます。

トレイトへのDebugの実装について、構造体であれば#[derive(Debug)] とするだけですが、トレイトへはderiveが使えないので直接実装する必要があるようです。

impl<T> std::fmt::Debug for dyn Specification<T> {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "")
    }
}

Specificationトレイトの実装対象について

Specificationトレイトは一つの型パラメータを受け取り、is_satisfied_byの引数としてその型を受け取ることで条件を満たしているかどうかを返すようにしています。

今回の実装ではLocalDate, LocalDateTime, CustomerをSpecificationトレイトの型パラメータとして受け取るようにしています。チケット料金表映画の日の判断にLocalDateを使い、平日、祝日 + レイトショーかどうかにLocalDateTime,  シネマシティズン、学生、一般などの顧客の判断にはCustomerを使うようにしています。

映画の日の判断

映画の日の判断は以下のように行っています。

use chrono::{Date, Datelike, Local};

use super::specification::Specification;
use super::structs::LocalDate;

pub const MovieDaySpec: MovieDaySpecification = MovieDaySpecification {};

pub struct MovieDaySpecification {}

impl Specification<LocalDate> for MovieDaySpecification {
    fn is_satisfied_by(&self, date: &LocalDate) -> bool {
        date.day() == 1
    }
}

映画の日はMovieDaySpecにSpecificationを実装することで表現しています。

平日、土日 + レイトショーかどうかの判断

まず平日、土日の判断は映画の日かどうかと似たように以下のように表現します。

use chrono::{Datelike, Weekday};

use super::specification::Specification;
use super::structs::{LocalDate, LocalDateTime};

pub enum BusinessDaySpec {
    Weekday,
    Holiday,
}
impl Specification<LocalDateTime> for BusinessDaySpec {
    fn is_satisfied_by(&self, date_time: &LocalDateTime) -> bool {
        match self {
            BusinessDaySpec::Holiday => !is_business_date_time(date_time),
            BusinessDaySpec::Weekday => is_business_date_time(date_time),
        }
    }
}

fn is_business_date_time(local_date: &LocalDateTime) -> bool {
    is_business_date(&local_date.date())
}

fn is_business_date(local_date: &LocalDate) -> bool {
    match local_date.weekday() {
        Weekday::Sun => false,
        Weekday::Sat => false,
        _ => true,
    }
}

平日、土日はenumを使って表現しています。チケット料金表では、平日、土日に加えてレイトショーかどうかの条件を追加できるようにする必要があるため、enumに対してはSpecificationではなくSpecificationを拡張しておき、OrSpecificationで合成できる必要があります。

次にレイトショーかどうかの判断は以下のようになります。

use super::specification::Specification;
use super::structs::LocalDateTime;

use chrono::Timelike;

pub const LateSpec8: LateSpecification = LateSpecification { start_hour: 20 };

pub struct LateSpecification {
    pub start_hour: i32,
}

impl Specification<LocalDateTime> for LateSpecification {
    fn is_satisfied_by(&self, local_date_time: &LocalDateTime) -> bool {
        self.start_hour <= local_date_time.hour() as i32
    }
}

これを用いて平日かつレイトショーでないという条件は以下のように表現できます。

Box::new(AndSpecification {
    spec1: Box::new(BusinessDaySpec::Weekday),
    spec2: Box::new(NotSpecification {
        spec: Box::new(LateSpec8),
    }),
}

顧客を判断できるようにする

顧客の条件を判断できるようにするため、まずは顧客を構造体で表現できるようにします。

#[derive(Debug)]
pub enum Gender {
    Male,
    Female,
}

#[derive(Debug, PartialEq)]
pub enum StudentCard {
    University,
    HighSchool,
    Elementary,
}

#[derive(Debug, PartialEq)]
pub enum Identification {
    MembershipCard,
    DisabilityHandbook,
    Student(StudentCard),
}

#[derive(Debug)]
pub struct Customer {
    pub birth_day: LocalDate,
    pub gender: Gender,
    pub identifications: Vec<Identification>,
}

impl Customer {
    pub fn has_identification(&self, identification: &Identification) -> bool {
        self.identifications.contains(&identification)
    }

    pub fn age(&self) -> i32 {
        let now_date = Local::now().naive_local();
        match (now_date.month() > self.birth_day.month())
            || (now_date.month() == self.birth_day.month()
                && now_date.day() >= self.birth_day.day())
        {
            true => now_date.year() - self.birth_day.naive_local().year(),
            false => now_date.year() - self.birth_day.naive_local().year() - 1,
        }
    }
}

それから、Specificationを実装したenumを定義して顧客を区別できるようにします。

use super::specification::Specification;
use super::structs::{Customer, Identification, StudentCard};

pub enum CustomerSpec {
    CinematicCitizen,
    CinemaCitizenSenior,
    General,
    Senior,
    UniversityStudent,
    HighSchoolStudent,
    ElementarySchoolStudent,
}

impl Specification<Customer> for CustomerSpec {
    fn is_satisfied_by(&self, customer: &Customer) -> bool {
        match self {
            CustomerSpec::CinematicCitizen => is_cinema_citizen(customer),
            CustomerSpec::CinemaCitizenSenior => is_cinema_citizen_senior(customer),
            CustomerSpec::Senior => is_senior(customer),
            CustomerSpec::UniversityStudent => is_university_student(customer),
            CustomerSpec::HighSchoolStudent => is_high_school_student(customer),
            CustomerSpec::ElementarySchoolStudent => is_elementary_school_student(customer),
            CustomerSpec::General => is_general(customer),
            _ => false,
        }
    }
}

fn is_cinema_citizen(customer: &Customer) -> bool {
    customer.has_identification(&Identification::MembershipCard)
}

fn is_cinema_citizen_senior(customer: &Customer) -> bool {
    customer.age() >= 60
}

fn is_senior(customer: &Customer) -> bool {
    customer.age() >= 70
}

fn is_university_student(customer: &Customer) -> bool {
    customer.has_identification(&Identification::Student(StudentCard::University))
}

fn is_high_school_student(customer: &Customer) -> bool {
    customer.has_identification(&Identification::Student(StudentCard::HighSchool))
}

fn is_elementary_school_student(customer: &Customer) -> bool {
    customer.has_identification(&Identification::Student(StudentCard::Elementary))
}

fn is_student(customer: &Customer) -> bool {
    is_university_student(customer)
        || is_high_school_student(customer)
        || is_elementary_school_student(customer)
}

fn is_general(customer: &Customer) -> bool {
    !is_cinema_citizen(customer)
        && !is_cinema_citizen_senior(customer)
        && !is_senior(customer)
        && !is_student(customer)
}

映画の判断条件を表現できるようにする

まず映画の判断条件となる顧客情報と映画の開始時刻を構造体でまとめたPlanConditionを作成します。

#[derive(Debug)]
pub struct PlanCondition {
    pub customer: Customer,
    pub local_date_time: LocalDateTime,
}

それから料金表の条件チェックとして使用するPlanSpecification構造体を作成します。PlanSpecificationには顧客条件と、平日、土日に加えてレイトショーかどうかまたは映画の日かを受け取れるようにしています。レイトショーかどうかの条件があるので映画の日と分けているのですが、映画の開始時刻は同一に扱えたほうが良さそうなので、これは改善の余地がありそうです。

それから、Specificationを構造体に実装させます。

use super::specification::Specification;
use super::structs::{Customer, LocalDate, LocalDateTime, PlanCondition};

#[derive(Debug)]
pub struct PlanSpecification {
    pub customer_spec: Box<dyn Specification<Customer>>,
    pub business_day_spec_opt: Option<Box<dyn Specification<LocalDateTime>>>,
    pub movie_day_spec_opt: Option<Box<dyn Specification<LocalDate>>>,
}

impl PlanSpecification {
    pub fn new(
        customer_spec: Box<dyn Specification<Customer>>,
        business_day_spec_opt: Option<Box<dyn Specification<LocalDateTime>>>,
        movie_day_spec_opt: Option<Box<dyn Specification<LocalDate>>>,
    ) -> Result<PlanSpecification, &'static str> {
        if business_day_spec_opt.is_some() && movie_day_spec_opt.is_some() {
            Err("PlanSpecification::new Error because both business day and movie day")
        } else if business_day_spec_opt.is_none() && movie_day_spec_opt.is_none() {
            Err("PlanSpecification::new Error because neither business day nor movie day")
        } else {
            Ok(PlanSpecification {
                customer_spec: customer_spec,
                business_day_spec_opt: business_day_spec_opt,
                movie_day_spec_opt: movie_day_spec_opt,
            })
        }
    }
}

impl Specification<PlanCondition> for PlanSpecification {
    fn is_satisfied_by(&self, plan_condition: &PlanCondition) -> bool {
        let customer_spec_result = self.customer_spec.is_satisfied_by(&plan_condition.customer);
        let movie_spec_result = match &self.movie_day_spec_opt {
            Option::None => true,
            Option::Some(movie_day_spec) => {
                movie_day_spec.is_satisfied_by(&plan_condition.local_date_time.date())
            }
        };
        let business_day_spec_result = match &self.business_day_spec_opt {
            Option::None => true,
            Option::Some(business_day_spec) => {
                business_day_spec.is_satisfied_by(&plan_condition.local_date_time)
            }
        };

        customer_spec_result && movie_spec_result && business_day_spec_result
    }
}

映画の判断条件とプラン名、料金を構造体で持てるようにする

映画の判断条件とプラン名、料金を構造体で保持できるようにします。

#[derive(Debug)]
pub struct Plan {
    pub name: PlanName,
    pub price: i32,
    pub spec: PlanSpecification,
}

impl Plan {
    pub fn is_satisfied_by(&self, plan_condition: &PlanCondition) -> bool {
        self.spec.is_satisfied_by(&plan_condition)
    }
}

#[derive(Debug)]
pub enum PlanName {
    CinemaCitizen,
    CinemaCitizenSenior,
    General,
    Senior,
    UniversityStudent,
    HighSchoolStudent,
    ElementaryStudent,
}

impl PlanName {
    pub fn to_string(&self) -> &str {
        match self {
            PlanName::CinemaCitizen => "シネマシティズン",
            PlanName::CinemaCitizenSenior => "シネマシティズン(60才以上)",
            PlanName::General => "一般",
            PlanName::Senior => "シニア(70才以上)",
            PlanName::UniversityStudent => "学生(大・専)",
            PlanName::HighSchoolStudent => "中・高校生",
            PlanName::ElementaryStudent => "小学生",
            _ => "",
        }
    }
}

プラン一覧情報の表現

先ほどのプラン構造体を使ってプランの一覧を扱えるようにします。定数として保持して使いまわせるようにするべきだと思うのですが、すぐに出来なかったのでとりあえず関数で毎回生成して返すようにしています。それから、料金でソートして返す関数も合わせて定義しておきます。

use super::business_day_specification::BusinessDaySpec;
use super::customer_specification::CustomerSpec;
use super::late_specification::{LateSpec8, LateSpecification};
use super::plan_specification::PlanSpecification;
use super::specification::{AndSpecification, NotSpecification, OrSpecification, Specification};
use super::structs::{Customer, DateFromStr, Gender, Identification, LocalDate, LocalDateTime};
use super::structs::{Plan, PlanCondition, PlanName};

fn plans() -> Vec<Plan> {
    let weekday_notlate_plans = vec![
        Plan {
            name: PlanName::CinemaCitizenSenior,
            price: 1000,
            spec: PlanSpecification {
                customer_spec: Box::new(CustomerSpec::CinemaCitizenSenior),
                business_day_spec_opt: Option::Some(Box::new(AndSpecification {
                    spec1: Box::new(BusinessDaySpec::Weekday),
                    spec2: Box::new(NotSpecification {
                        spec: Box::new(LateSpec8),
                    }),
                })),
                movie_day_spec_opt: Option::None,
            },
        },
        Plan {
            name: PlanName::CinemaCitizen,
            price: 1000,
            spec: PlanSpecification {
                customer_spec: Box::new(CustomerSpec::CinematicCitizen),
                business_day_spec_opt: Option::Some(Box::new(AndSpecification {
                    spec1: Box::new(BusinessDaySpec::Weekday),
                    spec2: Box::new(NotSpecification {
                        spec: Box::new(LateSpec8),
                    }),
                })),
                movie_day_spec_opt: Option::None,
            },
        },
        Plan {
            name: PlanName::General,
            price: 1800,
            spec: PlanSpecification {
                customer_spec: Box::new(CustomerSpec::General),
                business_day_spec_opt: Option::Some(Box::new(AndSpecification {
                    spec1: Box::new(BusinessDaySpec::Weekday),
                    spec2: Box::new(NotSpecification {
                        spec: Box::new(LateSpec8),
                    }),
                })),
                movie_day_spec_opt: Option::None,
            },
        },
        Plan {
            name: PlanName::General,
            price: 1800,
            spec: PlanSpecification {
                customer_spec: Box::new(CustomerSpec::General),
                business_day_spec_opt: Option::Some(Box::new(AndSpecification {
                    spec1: Box::new(BusinessDaySpec::Weekday),
                    spec2: Box::new(NotSpecification {
                        spec: Box::new(LateSpec8),
                    }),
                })),
                movie_day_spec_opt: Option::None,
            },
        },
    ];
    weekday_notlate_plans
}

fn sort(mut plans: Vec<Plan>) -> Vec<Plan> {
    plans.sort_by(|a, b| a.price.cmp(&b.price));
    plans
}

pub fn sort_plans() -> Vec<Plan> {
    sort(plans())
}

顧客、開始時刻からプランを判断できるようにする

最後に、PlanConditionを引数として受け取りプランを返す関数を定義しておきます。

use super::plans::sort_plans;
use super::structs::{Plan, PlanCondition};

pub fn order_price(plan_condition: PlanCondition) -> Option<i32> {
    order(plan_condition).map(|plan| plan.price)
}

pub fn order(plan_condition: PlanCondition) -> Option<Plan> {
    sort_plans()
        .into_iter()
        .find(|plan| plan.is_satisfied_by(&plan_condition))
}

これを使う場合は、以下のようになります。

extern crate ticket_modeling_rust;

use ticket_modeling_rust::order::order;
use ticket_modeling_rust::structs::*;

fn main() {
    let plan_condition = PlanCondition {
        customer: Customer {
            birth_day: LocalDate::date_from_str("1987/09/16"),
            gender: Gender::Male,
            identifications: vec![Identification::MembershipCard],
        },
        local_date_time: LocalDateTime::date_from_str("2020/03/20 13:00:00"),
    };
    println!("{:?}", order(plan_condition));
}

一通り実装してみると、Scalaの方は全てがObjectという恩恵が強く、Rustではメモリ安全の恩恵がある反面に変数を公開するのが簡単にはいかないということを改めて実感できました。