3
\$\begingroup\$

I have a problem similar to the question, namely reading in text which describes a date - in various formats. I want to use the parseTimeM function from Date.Time module in time package.

My current solution can probably be improved style wise but it should remain easy to read and easy to extend. Suggestion?

readDate3 :: Text ->   UTCTime
readDate3 datestring  =

    case shortMonth of
        Just t -> t
        Nothing -> case longMonth of
            Just t2 -> t2
            Nothing -> case monthPoint of
                Just t3 -> t3
                Nothing -> case germanNumeralShort of
                  Just t3 -> t3
                  Nothing -> case germanNumeral of
                    Just t3 -> t3
                    Nothing -> case isoformat of
                      Just t4 -> t4
                      Nothing -> errorT   ["readDate3", datestring, "is not parsed"]

    where
        shortMonth = parseTimeM True defaultTimeLocale
            "%b %-d, %Y" (t2s datestring) :: Maybe UTCTime
        longMonth = parseTimeM True defaultTimeLocale
            "%B %-d, %Y" (t2s datestring) :: Maybe UTCTime
        monthPoint = parseTimeM True defaultTimeLocale
            "%b. %-d, %Y" (t2s datestring) :: Maybe UTCTime
        germanNumeral = parseTimeM True defaultTimeLocale
            "%-d.%-m.%Y" (t2s datestring) :: Maybe UTCTime
        germanNumeralShort = parseTimeM True defaultTimeLocale
            "%-d.%-m.%y" (t2s datestring) :: Maybe UTCTime
        isoformat = parseTimeM True defaultTimeLocale
            "%Y-%m-%d" (t2s datestring) :: Maybe UTCTime
\$\endgroup\$

migrated from stackoverflow.com Mar 16 at 15:29

This question came from our site for professional and enthusiast programmers.

  • \$\begingroup\$ I think you can use the >>= combinator instead of this huge case expression. \$\endgroup\$ – ForceBru Mar 16 at 11:48
  • \$\begingroup\$ I tried but did not succeed (I nearly never use the >>= constructors. Can you show me the start of the chain? \$\endgroup\$ – user855443 Mar 16 at 11:53
  • 1
    \$\begingroup\$ @ForceBru That's not applicable in this case - >>= continues the computation on Just, whereas here we need to continue it on Nothing. \$\endgroup\$ – bradrn Mar 16 at 11:59
  • \$\begingroup\$ @user855443 Small detail: >>= is a function, not a constructor. I know that's nitpicking a bit, but these details can start to matter in more advanced stuff (e.g. you can pattern-match on constructors but not on functions). \$\endgroup\$ – bradrn Mar 16 at 13:10
4
\$\begingroup\$

Don't repeat yourself

Your code violates the DRY principle. If we replace

germanNumeralShort = parseTimeM True defaultTimeLocale
    "%-d.%-m.%y" (t2s datestring) :: Maybe UTCTime
isoformat = parseTimeM True defaultTimeLocale
    "%Y-%m-%d" (t2s datestring) :: Maybe UTCTime
...

with

parse format = parseTimeM True defaultTimeLocale format (t2s datestring)

germanNumeralShort = parse "%-d.%-m.%y"
isoformat          = parse "%Y-%m-%d"
...

then we immediately notice that we use parse on all formats after another till we find a suitable one.

This can be modelled with map parse, e.g.

map parse
    [ "%b %-d, %Y"
    , "%B %-d, %Y"
    , "%b. %-d, %Y"
    , "%-d.%-m.%Y"
    , "%-d.%-m.%y"
    , "%Y-%m-%d"
    ]

We could use <|> alternatively, e.g.

parse "%b %-d, %Y" <|> parse "%B %-d, %Y" <|> ...

but that's less flexible than the list approach.

Use asum to get a single Maybe from a list of Maybes

To get a single Maybe a from [Maybe a], we can use asum. To get the errorT, we just need to pattern match on a single result and end up with

readDate3 :: Text ->   UTCTime
readDate3 datestring = case result of
   Nohing -> errorT ["readDate3", datestring, "is not parsed"]
   Just t -> t
 where 
   parse format = parseTimeM True defaultTimeLocale format (t2s datestring) 
   result = asum . map parse $
        [ "%b %-d, %Y"
        , "%B %-d, %Y"
        , "%b. %-d, %Y"
        , "%-d.%-m.%Y"
        , "%-d.%-m.%y"
        , "%Y-%m-%d"
        ]

As the current strings are missing some documentation, we could introduce additional types to remedy that:

data DateFormat = ShortMonth
                | LongMonth
                | MonthPoint
                | GermanNumeral
                | GermanNumeralShort
                | ISOFormat

toFormatString :: DateFormat -> String
toFormatString f = case f of
    ShortMonth -> "%b %-d, %Y"
    LongMonth  -> "%B %-d, %Y"
    MonthPoint -> "%b. %-d, %Y"
    -- other left as an exercise

We can also use fromMaybe to get rid of the last pattern match and end up with

import Data.Foldable (asum)
import Data.Maybe (fromMaybe)

readDate :: Text ->   UTCTime
readDate datestring = 
    fromMaybe (errorT ["readDate", datestring, "is not parsed"]) $
      asum . map parse $
        [ ShortMonth
        , LongMonth
        , MonthPoint
        , GermanNumeral
        , GermanNumeralShort
        , ISOFormat
        ]
 where 
   parse format = parseTimeM True defaultTimeLocale (toFormatString format) (t2s datestring) 
\$\endgroup\$
  • \$\begingroup\$ The final pattern match can be replaced by fromMaybe. \$\endgroup\$ – melpomene Mar 16 at 13:18
  • \$\begingroup\$ @melpomene added that; it has been a while since I answered Haskell questions and used the language, thanks. \$\endgroup\$ – Zeta Mar 16 at 13:22
  • \$\begingroup\$ @4castle thanks for the edit. I don't have a programming environment or editor at the moment at hand (not even a spell checker). \$\endgroup\$ – Zeta Mar 16 at 13:32
2
\$\begingroup\$

Define an auxilliary function:

replaceIfNothing :: Maybe a -> a -> a
replaceIfNothing (Just x) _ = x
replaceIfNothing Nothing  x = x

And then you can do:

replaceIfNothing shortMonth $
replaceIfNothing longMonth $
replaceIfNothing monthPoint $
-- I think you get the idea now

You can also do it as an operator, which I personally think is nicer:

(&>) :: Maybe a -> a -> a
(&>) (Just x) _ = x
(&>) Nothing  x = x
infixr 1 &>

shortMonth &> longMonth &> monthPoint &> ...

Of course, since this is Haskell, a quick search shows that this is just Data.Maybe.fromMaybe with the arguments reversed; you can thus just define replaceIfNothing = flip fromMaybe (although you have to do import Data.Maybe first). It's possible to use fromMaybe directly as well, although it feels a little clumsy:

flip fromMaybe shortMonth $
flip fromMaybe longMonth $
flip fromMaybe monthPoint $
...
\$\endgroup\$
  • 2
    \$\begingroup\$ Why not just shortMonth <|> longMonth <|> monthPoint <|> ... (using <|> from Control.Applicative)? \$\endgroup\$ – melpomene Mar 16 at 12:37
  • \$\begingroup\$ @melpomene Yes, that's even better! I completely forgot about (<|>)! I'll edit that in now. \$\endgroup\$ – bradrn Mar 16 at 12:39
  • \$\begingroup\$ @melpomene After a bit more thought, it actually turns out that (<|>) is not exactly the same as what I've defined: I defined (&>) :: Maybe a -> a -> a, whereas (<|>) :: Maybe a -> Maybe a -> Maybe a. \$\endgroup\$ – bradrn Mar 16 at 12:42
  • 1
    \$\begingroup\$ <|> can still be used, you would just unwrap the end result with fromMaybe (for the error case) \$\endgroup\$ – 4castle Mar 16 at 13:03
  • \$\begingroup\$ @4castle That is definitely one way to go about it. I do think that approach is slightly more boilerplate-ey than necessary though; compare a &> b &> c to fromJust $ a <|> b <|> Just c. \$\endgroup\$ – bradrn Mar 16 at 13:08

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.