Chris Stryczynski
Chris Stryczynski

Reputation: 33991

How can I handle nested case statements without an indentation tree?

I occasionally run into a minor issue where I've got a set of nested case statements, which becomes cumbersome to handle. Is there any technique / pattern I can use to possibly have a list of functions (which would be equivalent to the examples's case ... statements), evaluate all of them, and pick the first that matches a pattern (example: Right x)?

A specific issue I have with putting them in a list, is they won't necessarily be of the same type (the monomorphism restriction I think). For example the following:

  let possibleTags = [
          parse (parseFileReference) "file reference" xStr
        , parse (parseGitDiffReference) "git diff tag" xStr
        ]

Produces the following error:

• Couldn't match type ‘GitDiffReference’ with ‘FileReference’
  Expected type: Either ParseError FileReference
    Actual type: Either ParseError GitDiffReference
• In the expression:
    parse (parseGitDiffReference) "git diff tag" xStr
  In the expression:
    [parse (parseFileReference) "file reference" xStr,
     parse (parseGitDiffReference) "git diff tag" xStr]
  In an equation for ‘possibleTags’:
      possibleTags
        = [parse (parseFileReference) "file reference" xStr,
           parse (parseGitDiffReference) "git diff tag" xStr]

abc :: Int -> String
abc = undefined

abc2 :: Int -> Float
abc2 = undefined

abc3 :: Int -> Int
abc3 = undefined

example :: Int -> Maybe String
example x = case abc x of 
  ("yes") -> Just "abc"
  ("no")  -> case abc2 x of
               1.0 -> Just "abc2"
               2.0 -> case abc3 x of
                        100 -> Just "abc3"
                        200 -> Nothing

I'm ideally looking for something like the below (not valid code):

-- Psuedo code
example :: Int -> Maybe String
example = 
 if (abc x && "yes") then Just "abc"
 if (abc2 x && 1.0) then Just "abc2"
 if (abc3 x && 100) then Just "abc3"

The problem with using if conditional is I can't(to my knowledge) do a pattern match, for example if (Just x).

Upvotes: 5

Views: 1961

Answers (3)

chi
chi

Reputation: 116174

Translating the exact example (keeping the non-exhaustive pattern matching as it is):

import Data.Foldable


example2 :: Int -> Maybe String
example2 x = asum tests
   where
   tests =
      [ case abc x of 
         "yes" -> Just "abc"
         "no"  -> Nothing
      , case abc2 x of
         1.0 -> Just "abc2"
         2.0 -> Nothing
      , case abc3 x of
         100 -> Just "abc3"
         200 -> Nothing
      ]

Upvotes: 6

Silvio Mayolo
Silvio Mayolo

Reputation: 70307

I'm going to work with your original example. A contrived example like your second one is unlikely to simplify nicely, whereas your first one looks like monad transformer code is actually something you might see in production.

Looking for a general "if-then-case" statement like you seem to is not going to end fruitfully. That's because case-statements are really the catch-all in Haskell, and reductions are going to be more specific, not more general, than case. So with that in mind, let's take a look at your code.

parseLine :: Text -> IO (Either String Text)
parseLine x = do
  let xStr = convertString x :: String
  case parse (parseFileReference) "file reference" xStr of
    Right z -> do
      print z
      fileReferenceContent z >>= \case
        Just v' -> return2x $ "```\n" <> v' <> "```"
        Nothing -> return $ Left "Unable to parse"
    Left _ -> case parse (parseGitDiffReference) "git diff tag" xStr of
        Right z -> do
          print z
          return2x $ ""
        Left _ -> case parse (parsePossibleTag) "possible tag" xStr of
          Right _ -> return $ Left $ "Tag that failed to match: " ++ xStr
          Left _ -> return2x x

In each case, when you parse, you ignore the contents if it's a Left. So let's strip out that pointless data that you ignore.

eitherToMaybe :: Either a b -> Maybe b
eitherToMaybe (Left _) = Nothing
eitherToMaybe (Right x) = Just x

parse' :: Foo0 -> String -> String -> Maybe Bar -- where Foo0 and Bar are the correct types
parse' foo s0 s1 = eitherToMaybe $ parse foo s0 s1

Then call parse' instead of parse. This doesn't, as yet, improve anything. But let's refactor a bit more and then we'll see what it gets us. I want all of the "success" cases (which were Right z before and are Just z in the new parse' code) to be in a where block.

parseLine x = do
  let xStr = convertString x :: String
  case parse' (parseFileReference) "file reference" xStr of
    Just z -> fileRef z
    Nothing -> case parse' (parseGitDiffReference) "git diff tag" xStr of
        Just z -> gitDiff z
        Nothing -> case parse' (parsePossibleTag) "possible tag" xStr of
          Just _ -> tagMatch xStr
          Nothing -> return2x x
    where fileRef z = do
                print z
                fileReferenceContent z >>= \case
                      Just v' -> return2x $ "```\n" <> v' <> "```"
                      Nothing -> return $ Left "Unable to parse"
          gitDiff z = do
                print z
                return2x ""
          tagMatch xStr = do
                return (Left $ "Tag that failed to match: " ++ xStr)

Once again, no real benefit yet. But here's the thing: Maybe has an Alternative instance that does exactly what you want, whereas Either does not. Specifically, it will select the first successful match while ignoring the others. Let's pull out the matches and then go from there.

parseLine :: Text -> IO (Either String Text)
parseLine x = do
  let xStr = convertString x :: String
  case parseFile xStr of
    Just z -> fileRef z
    Nothing -> case parseDiff xStr of
        Just z -> gitDiff z
        Nothing -> case parseTag xStr of
          Just _ -> tagMatch xStr
          Nothing -> return2x x
    where parseFile = parse' (parseFileReference) "file reference"
          parseDiff = parse' (parseGitDiffReference) "git diff tag"
          parseTag  = parse' (parsePossibleTag) "possible tag"
          fileRef z = do
                print z
                fileReferenceContent z >>= \case
                      Just v' -> return2x $ "```\n" <> v' <> "```"
                      Nothing -> return $ Left "Unable to parse"
          gitDiff z = do
                print z
                return2x ""
          tagMatch xStr = do
                return (Left $ "Tag that failed to match: " ++ xStr)

Now here's where the magic happens. Alternative for Maybe will select the first successful match. So we're going to use asum, which sums up a list of alternatives using the Alternative instance.

parseLine :: Text -> IO (Either String Text)
parseLine x = do
  let xStr = convertString x :: String
  let result = asum [
                fileRef <$> parseFile xStr,
                gitDiff <$> parseDiff xStr,
                tagMatch xStr <$ parseTag xStr -- Not a typo, the operator is (<$) since you ignore the value
               ]
  maybe failure id result
    where ...
          failure = return2x x

At the end, we're left with result :: Maybe (IO (Either String Text)). If it exists, we want to return it unmodified. If it doesn't, we want to fall back tofailure. So we usemaybe`, which is more compact than a full case statement here.

Finally, in fileRef we can factor out the pattern of "custom error message on failure".

maybeToEither :: a -> Maybe b -> Either a b
maybeToEither x Nothing  = Left x
maybeToEither _ (Just y) = Right y

Then that statement becomes

fileRef z = do
     print z
     value <- maybeToEither "Unable to parse" <$> fileReferenceContent z
     return $ (\v' -> "```\n" <> v' <> "```") <$> value

So the final code looks like

eitherToMaybe :: Either a b -> Maybe b
eitherToMaybe (Left _) = Nothing
eitherToMaybe (Right x) = Just x

parse' :: Foo0 -> String -> String -> Maybe Bar -- where Foo0 and Bar are the correct types
parse' foo s0 s1 = eitherToMaybe $ parse foo s0 s1

maybeToEither :: a -> Maybe b -> Either a b
maybeToEither x Nothing  = Left x
maybeToEither _ (Just y) = Right y

parseLine :: Text -> IO (Either String Text)
parseLine x = do
  let xStr = convertString x :: String
  let result = asum [
                fileRef <$> parseFile xStr,
                gitDiff <$> parseDiff xStr,
                tagMatch xStr <$ parseTag xStr
               ]
  maybe failure id result
    where parseFile = parse' (parseFileReference) "file reference"
          parseDiff = parse' (parseGitDiffReference) "git diff tag"
          parseTag  = parse' (parsePossibleTag) "possible tag"
          fileRef z = do
                print z
                value <- maybeToEither "Unable to parse" <$> fileReferenceContent z
                return $ (\v' -> "```\n" <> v' <> "```") <$> value
          gitDiff z = do
                print z
                return2x ""
          tagMatch xStr = do
                return (Left $ "Tag that failed to match: " ++ xStr)
          failure = return2x x

It's not shorter than the original code, but it has less nested case alternatives, and it certainly reads more linearly.

Upvotes: 3

leftaroundabout
leftaroundabout

Reputation: 120731

At least when the result is Maybe, this looks like a clear-cut use case for the Alternative instance:

example x = ("abc" <$ guard (abc x=="yes"))
         <|>("abc2" <$ guard (abc2 x==1))
         <|>("abc3" <$ guard (abc3 x==100))

Upvotes: 5

Related Questions