đź“° Exercism - Rust - Raindrops

Posted on May 20, 2022 by Myoungjin Jeon
Tags: rust, elm, haskell

I kept doing learning Rust via exercism.org. and I learnt some basic usage of functions, but still don’t have enough concrete understandings.. However, I found something similar in syntax in rust to haskell or elm. let’s look at the problem first.

Introductions

Your task is to convert a number into a string that contains raindrop sounds corresponding to certain potential factors. A factor is a number that evenly divides into another number, leaving no remainder. The simplest way to test if a one number is a factor of another is to use the modulo operation.

The rules of raindrops are that if a given number:

has 3 as a factor, add 'Pling' to the result.
has 5 as a factor, add 'Plang' to the result.
has 7 as a factor, add 'Plong' to the result.
does not have any of 3, 5, or 7 as a factor, the result should be the digits of the number.

Examples

28 has 7 as a factor, but not 3 or 5, so the result would be "Plong".
30 has both 3 and 5 as factors, but not 7, so the result would be "PlingPlang".
34 is not factored by 3, 5, or 7, so the result would be "34".

First Iteration

This is categorised as an easy problem. and I solve the problem by using .map() and .join().

pub fn raindrops(n: u32) -> String {
    let divisible_by = |k| -> bool { n % k == 0 };

    let sound = [(3, "Pling"), (5, "Plang"), (7, "Plong")]
        .map(|(k, sound)| if divisible_by(k as u32) { sound } else { "" })
        .join("");

    if sound.is_empty() {
        n.to_string()
    } else {
        sound
    }
}

divisible_by

this is not a function but it is called closure. Rust allows us to declare a function inside another function but the local function is unabled to access local variables, which is uncomfortable for me because In haskell, we could make a local function which can access the variables in current scope:

raindrops :: Int -> String
raindrops n =
  let divisible_by k = n `rem` k == 0
  -- snip ..

So, I tried to use closure here. And it is great to see it is working as I wanted.

filter_map is more efficient

I could have used filter_map() instead of map(). so if I revise the iteration 1, it would be like:

// .. snip ..
    let sound = [(3, "Pling"), (5, "Plang"), (7, "Plong")]
        .iter()
        .filter_map(|(k, sound)| {
            if divisible_by(*k as u32) {
                Some(sound.to_string())
            } else {
                None
            }
        })
        .collect::<String>();
// .. snip ..

And filter_map in rust is actually efficient than filter().map() combination.

Second Iteration

And I found some other’s iteration using fold. In functional programming, this is kind of folding problem. which sounds familiar to me, so I tried to figure out how to use it.

And this is raindrops function by using fold:

// .. some structure and implementation ..
// snip ..
pub fn raindrops(n: u32) -> String {
    let divisible_by = |k| -> bool { n % k == 0 };

    // fold_helper will be used later
    let fold_helper = |acc: Option<String>, fac_sound: &FacToSound| {
        if divisible_by(fac_sound.get_factor()) {
            let new_sound = fac_sound.get_sound();
            acc
                .map(|s| s + new_sound)
                .or_else(|| Some(String::from(new_sound)))
        } else {
            acc
        }
    };

    RAIN_DROP_SOUND_MAP
        .iter()
        .fold(None, fold_helper)
        .unwrap_or_else(|| n.to_string())
        // I think unwrap_or_else is cool stuff
}

So fold in rust is basically foldl in haskell. The accumulator is the first argument as same as in haskell as well. I enjoyed the struggling to reach this solution. but I didn’t like the part shown below:

acc
    .map(|s| s + new_sound)
    .or_else(|| Some(String::from(new_sound)))

because .or_else will always check the result of acc.map() is Some (Just in Haskell) or None (Nothing in Haskell).

If I use the pattern matching, It runs either .map() part or .or_else part. Even if it looks fancy or expressive, it can lead us to write less efficient code and also make the codes for reviewer who is not familiar with extra syntax to understand.

leave it as basic

In Elm lanauge, Maybe-Extra package exisits where orElse exists as well.

Just 5
    |> orElse (Just 4)
--> Just 5

Nothing
    |> orElse (Just 7)
--> Just 7

But that function comes from an extra package! Because elm language encourages the user use simpler syntax, which is pattern matching in this case. (this quite strong point of view, but I agree that it is not good idea to write many different version of function for an open source project which is involved by many people.)

case Just 5 of
    Nothing ->
        Just 4
    x ->
        x

It results in a bit longer codes than one with helper package, nevertheless the user still understand the code easier with plain syntax background. No need to look up the extra documentation! even though your IDE will teach you how to use it thesedays. But reading a documentation is still stressful.

so finally I made another iteration.

Third Iteration

Note: I add more types and implentation for study purpose.

struct FacToSound(u32, &'static str);

impl FacToSound {
    pub fn get_factor(&self) -> u32 {
        self.0
    }

    pub fn get_sound(&self) -> &str {
        self.1
    }
}

const RAIN_DROP_SOUND_MAP: [FacToSound; 3] = [
    FacToSound(3, "Pling"),
    FacToSound(5, "Plang"),
    FacToSound(7, "Plong"),
];

pub fn raindrops(n: u32) -> String {
    let divisible_by = |k| -> bool { n % k == 0 };
    let fold_helper = |acc: Option<String>, fac_sound: &FacToSound| {
        if divisible_by(fac_sound.get_factor()) {
            let new_sound = fac_sound.get_sound();
            Some(match acc {
                Some(sound_so_far) => sound_so_far + new_sound,
                None => String::from(new_sound),
            })
        } else {
            acc
        }
    };

    RAIN_DROP_SOUND_MAP
        .iter()
        .fold(None, fold_helper)
        .unwrap_or_else(|| n.to_string())
}

Haskell Version

I didn’t expect the rust code can be easily tranlated into haskell code. Actually it was!

{-# LANGUAGE OverloadedStrings #-}

module Raindrops (convert)  where

import qualified Data.Text as T
import           Data.Text (Text)
import qualified Data.Maybe as MB

convert :: Int -> Text
convert n =
  MB.fromMaybe (T.pack . show $ n) . (foldr helper Nothing) $
  ([ (3, "Pling")
   , (5, "Plang")
   , (7, "Plong")
   ] :: [ (Int, Text) ])

  where
    helper (k, sound) acc 
      | n `rem` k == 0 =
          case acc of
            Nothing ->
              Just sound
            _ ->
              (sound <>) <$> acc
      | otherwise = acc

Data.Text is used here, so code is less straightforward. and foldr is used here instead of fold in Rust (which is actually foldl in haskell). The order of catenating texts is different from the one in fold. i.e:

// append new sound to acc
// in fold in rust  
Some(sound_so_far) => sound_so_far + new_sound,

vs

-- preppend new sound to acc. in foldr  in haskell
_ ->
  (sound <>) <$> acc

If you are not comfortable with <$> operator, you can use fmap. and I put a lamda variable to how it really works. (Frankly speaking, I wish elm had this kind of feature but it is not supported on purose!!)

-- preppend new sound to acc. in foldr  in haskell
_ ->
  fmap (\acc' -> sound <> acc') acc

Wrapping Up