Haskell, DSL and Monad
June 04, 2007
Haskell is an amazing language. One can easily embed a DSL (domain specific language) using monad. Let's take an concrete example to illustrate the power of DSL and Monads in Haskell.
I was always fascinated by a very useful, and nifty program, called remind. It is a very sophisticated calendering program that allows one to set reminders. For example, "REM 4 July MSG It's Independence Day!", will trigger an reminder on every July 4th. However, this kind of simple reminders can be set using any calendering application. 'Remind' provides a very flexible date specification for setting reminders. The date spec consists of 'day month year weekday'. If you omit the date spec, the reminder occurs every day. If you specify only the day part, then the reminder is triggered on the specified day of every month. If only month is specified, then the reminder is triggered every day of the specified month. And so on... You can take a look at the man page of the remind for more details about the date spec rules.
Remembering the above rules can be tricky. Instead, let's see if Haskell can help us here. I used an idea proposed by Ketil Malde on Haskell-Cafe mailing list on how to handle calendar dates in Haskell. Here is the basic idea:
data Year = Y Int
data MonthEnum = January | February | March | April | May | June | July | August
| September | October | November | December deriving (Show, Eq, Enum, Bounded, Ord)
data DayEnum = Sunday | Monday | Tuesday | Wednesday | Thursday | Friday | Saturday deriving (Show, Eq, Enum, Bounded)
data Month = M MonthEnum Year
data Day = Dm Int Month
Haskell uses lazy evaluation by default. This makes it easier to represent an infinite stream of years starting from 2007 as:
years = [Y y | y <- [2007..]]
Using similar list comprehension notation, list of months in a given year and list of days in a given months can be represented as,
months (Y y) = [M m (Y y) | m <- [January .. December]]
days (M m yy@(Y y))
| m `elem` [January,March,May,July,August,October,December] = [Dm d (M m yy) | d <- [1..31]]
| m == February = [Dm d (M m yy) | d <- [1..if y `mod` 4 == 0 then 29 else 28]]
| otherwise = [Dm d (M m yy) | d <- [1..30]]
Notice how leap years are handled in producing days in a given month and year.
The above code was taken from the Ketil Malde's message at Haskell-Cafe and converted to add Enums.
I added some boilerplate code to display the months properly by implementing an instance of the type class, Show:
instance Show Month where
show (M m t) = show m ++ " " ++ show tinstance Show Year where
show (Y y) = " " ++ show yinstance Show Day where
show (Dm d t) = " " ++ show d ++ " " ++ show t
With the above addition, and using the fact that List in Haskell is a monad, we can easily represent the following,
-- all days of all months of all years (only the first 3 items are shown)
*DateStream> take 3 (years >>= months >>= days)
[ 1 January 2007, 2 January 2007, 3 January 2007]
-- first day of every month of every year
*DateStream> take 3 (years >>= months >>= return.(Dm 1))
[ 1 January 2007, 1 February 2007, 1 March 2007]-- all days of january of every year
*DateStream> take 3 (years >>= return.(M January) >>= days)
[ 1 January 2007, 2 January 2007, 3 January 2007]
Notice, how easy it is to express the list of days that you are interested in. You can even filter the days by using the 'filter' function provided in Haskell for lists:
-- all mondays in June of any year starting from 2007
*DateStream> take 6 (filter (isDayOfWeek Monday) (years >>= return.(M June) >>=
days))
[ 4 June 2007, 11 June 2007, 18 June 2007, 25 June 2007, 2 June 2008, 9 Jun
e 2008]
'isDayOfWeek' function requires calculating the 'day of week'.
All this functionality in just 43 lines of code (including comments). This already implements all combinations of Remind's date spec that does not involve day of week. It shouldn't be hard to implement the same for day of week.
Most importantly, the user only needs to use simple keywords, such as, years, months, days, january and combine them using the monadic bind operator, '>>='. There, you have a small DSL for calendar apps. You can browse the file here.
Feedback, corrections and suggestions are most welcome.
Update: As some readers pointed out the leap year calculation were wrong. Thanks for the feedback. I have corrected the code to use the gregorianMonthLength function from the Data.Time.Calendar module.
This is fabulous report. I loved to see your blog with great presentation.
Posted by: handelspand te koop | March 15, 2009 at 10:20 PM
how define isDayOfWeek
Posted by: 123 | August 25, 2007 at 01:00 AM
Joachim: Noooo! Don't say that! That's what they said about the year 2000...
Posted by: Miles | June 05, 2007 at 06:25 AM
Nice, but I'm not sure it's as DSLish as, say, Ruby's
:-)Posted by: Miles | June 05, 2007 at 05:31 AM
Your formula for the number of days in February is not quite right: if the year number is divisible by 100 and not 400, it is not a leap year. So 2000 was a leap year, but 1900 wasn't. You need to replace:
if y `mod` 4 == 0
with
if y `mod` 4 == 0 && (y `mod` 100 /= 0 || y `mod` 400 == 0)
Posted by: Pete | June 04, 2007 at 12:53 PM
A minor correction: the definition of the leap years should take into account the century years e.g. 1900 was not a leap year. Something like
will be ok.cheers, stelios
Posted by: Stelios Sfakianakis | June 04, 2007 at 09:54 AM
Just a minor note: Your leap year code is not correct, as every 100th, but not 400th, year is not a leap year. Maybe something like
> if (y `mod` 4 == 0 and y `mod` 100 /= 0) || y `mod` 400 == 0 then 29 else 28
Not that it really matters if the code is wrong in 2100, I guess :-)
Posted by: Joachim Breitner | June 04, 2007 at 09:34 AM