rustswitch-statementpattern-matchingcode-readability

Alternatives to if-else chains with complex conditions in Rust


In rust I sometimes need to write chained if-else statements. They are not the nicest way to manage multiple conditionals with multiple things to check in the conditions.

Here is an artificial rust-playgroung example of what I mean and in the following code you can see the if-else chain in question.

// see playground for rest of the code

fn check_thing(t: Thing) -> CarryRule {
    let allowed = vec!["birds","dogs","cats","elefants","unknown","veggies","meat"];
    let max_carry_size = 30;
    let max_carry_unknown_size = 3;
    let max_carry_dog_size = 5;
    let typ = &t.typ.as_str();

    if t.size > max_carry_size {
        CarryRule::Forbidden
    } else if ! allowed.contains(typ) {
        CarryRule::Forbidden
    } else if t.typ == "unknown" && t.size > max_carry_unknown_size {
        CarryRule::Forbidden
    } else if t.typ == "dogs" && t.size > max_carry_dog_size {
        CarryRule::Forbidden
    } else if t.typ == "birds" {
        CarryRule::UseCage
    } else {
        CarryRule::UseBox
    }
}

I know, I should use a match statement, but I do not see how I can do all of the checks above using a single match block. I would need to

I am looking for Rust version of Go's non-parametrized switch-case such as the following.

switch {
case a && b: return 1
case c || d: fallthrough
case e || f: return 2
default:     return 0
}

Of course, I could also refactor the whole example, modelling t.size, t.typ, and the allowed list in a more consistent way that allows nicer match blocks. But sometimes these types are outside of my control and I do not want to wrap the given types in too much extra wrapping.

What are good readable alternatives to such if-else chains with complex conditions in Rust?


Solution

  • Based on the good recommendations in the other answers, here is a playground with my preferred solution, which uses the following new check_thing function.

    const MAX_CARRY_SIZE: usize= 30;
    const MAX_CARRY_UNKNOWN_SIZE: usize = 3;
    const MAX_CARRY_DOG_SIZE: usize = 5;
    const FORBIDDEN: [Typ; 1] = [Typ::Cake];
    
    fn check_thing(t: Thing) -> CarryRule {
        // First apply the general rules and use early returns.
        // This is more readable then if-else-ing all cases from the beginning.
        if t.size > MAX_CARRY_SIZE {
            return CarryRule::Forbidden
        }
        if FORBIDDEN.contains(&t.typ) {
            return CarryRule::Forbidden
        }
    
        // Match all cases that need special handling using a single `enum`.
        // The rust compiler will help you to have a match for any enum value.
        // Implement special case logic as match guards for the matching enum value.
        match t.typ {
            Typ::Dog if t.size > MAX_CARRY_DOG_SIZE => CarryRule::Forbidden,
            Typ::Unknown if t.size > MAX_CARRY_UNKNOWN_SIZE => CarryRule::Forbidden,
            Typ::Bird => CarryRule::UseCage,
            // use a default for all non-special cases
            _ => CarryRule::UseBox,
        }
    }
    

    This solution adopts the proposed enums and match guards but also focusses on readability. It tries to keep all if-else logic in the check_thing function, where it was before. And as before, it applies this logic step by step as it was in the if-else chain.

    But let's come back to the original question:

    What are good readable alternatives to such if-else chains with complex conditions in Rust?

    Here are some options that can help and that can be used complementary.

    Match & Guard

    Use a simple match statement (one argument only) and implement some of the complexity as match guards. Also, do not use strings for matching, but an enum. This way the rust compiler can help you covering all the logic.

    A longer match with a single argument and additional guards can stay quite readable and is the closest you can get if you are looking for something like Go's plain switch.

    Split Up

    Split up the if-else blocks in separate parts and use early returns instead of adding more else blocks (see the generic and specific parts in the example). If your match guards are too complex find the generic parts and move them up.

    Generalize

    Express your complex conditions as your object's configuration rather than in long if-else chains. Using a trait or simply adding an enum can help to capture some of the logic and make your entities more configurable.

    This kind of refactoring and generalization requires more code changes if you start out with a big complex if-else chain. But it can make your logic configurable and extensible, and may often be the preferred solution if you are the owner of the code base anyway.

    Here is this generalized version.

    
    const MAX_CARRY_SIZE: usize= 30;
    const MAX_CARRY_UNKNOWN_SIZE: usize = 3;
    const MAX_CARRY_DOG_SIZE: usize = 5;
    
    impl Typ {
        pub fn is_carriable(&self) -> bool {
            match self {
                Self::Cake => false,
                _ => true,
            }
        }
        pub fn carry_rule(&self) -> CarryRule {
            match self {
                Self::Bird => CarryRule::UseCage,
                _ => CarryRule::UseBox,
            }
        }
    }
    
    impl Thing {
        fn has_allowed_carry_size(&self) -> bool {
            if self.size > MAX_CARRY_SIZE {
                return false
            }
            match self.typ {
                Typ::Dog => self.size <= MAX_CARRY_DOG_SIZE,
                Typ::Unknown => self.size <= MAX_CARRY_UNKNOWN_SIZE,
                _ => true
            }
        }
    
        pub fn is_carriable(&self) -> bool {
            self.typ.is_carriable() && self.has_allowed_carry_size()
        }
    
        pub fn carry_rule (&self) -> CarryRule {
            if !self.is_carriable() {
                return CarryRule::Forbidden
            }
            self.typ.carry_rule()
        }
    }
    
    pub fn check_thing(t: Thing) -> CarryRule { t.carry_rule() }
    
    

    As you can see, it separates the Typ and size logic. This has the benefit of making things configurable and reusable, but also has the drawback that the carry logic is now in different places. You can no longer read the rules from top to bottom as in the original if-else block or in my preferred solution at the beginning of the post.

    It is up your project requirements which solution you should take.

    Finally, thank you for all comments and answers, and for teaching me some more Rust today!