2008 Winter Scripting Games

Solution to Beginner Perl Event 5: What’s the Difference?

Event 5 Solution


Perl solution to Event 5 in the 2008 Winter Scripting Games.

Solutions are also available for VBScript and Windows PowerShell.

*

Event 5 – What’s the Difference?

Before we launch into our solution for this event we’d like to apologize to our Perl beginning scripters. Our initial intent was to use only the functionality that shipped with ActivePerl 5.8.8;we didn’t intend to require the installation of any additional modules. Now, we’ll admit that, VBScript having been the primary scripting language used on the Script Center for all its years of existence, we created these events with VBScript in mind, assuming a solution that’s fairly straightforward in VBScript would be at least as straightforward in Windows PowerShell and Perl. And for the most part that’s true – until it comes to dates. We’ll come right out and say it: we found working with dates in Perl to be somewhat…less than pleasant …. This event seemed to be much more difficult to solve in Perl than in VBScript, and while the Windows PowerShell solution had its complications, Perl seemed to have even more. We quickly came to the conclusion that this event would be close to impossible without the use of the Date::Calc module. So, this being a beginners event, we tried to give you a little help ahead of time. We hope that in the end this event wasn’t too painful.

To successfully complete this event you needed to learn about working with dates in Perl. Depending on how you went about solving the problem, you most likely needed to begin by learning to install additional modules, namely the Date::Calc module. That’s what we did anyway, and here’s the solution we wound up with:

use Date::Calc qw(:all);

($year,$month,$day) = Today();

$ddate = $ARGV[0];
($inyear,$inmonth,$inday) = Decode_Date_US($ddate);

$Ddays = Delta_Days($year,$month,$day, $inyear,$inmonth,$inday); 

print "Days: $Ddays\n";

@YMD = Delta_YMD($year,$month,$day, $inyear,$inmonth,$inday);

$mo = ($YMD[0] * 12) + $YMD[1];

print "Months: $mo\n";

if ($inday < $day)
{
    $mo = $mo - 1;
}

($tyear,$tmonth,$tday) =
      Add_Delta_YM($year,$month,$day, 0,$mo);

$xdays = Delta_Days($tyear,$tmonth,$tday, $inyear,$inmonth,$inday);

print "Months/Days: $mo / $xdays";

As we mentioned, we solved this event using the methods made available with the Date::Calc module. That means we needed to include this module in our script with this use statement:

use Date::Calc qw(:all);

Here we’re simply saying that we want to make all the methods of the Date::Calc module available for use in the script.

The object of this event was to compare two dates: today’s date and a date input as a command-line parameter to the script. So we start by calling the Date::Calc method Today to retrieve today’s date:

($year,$month,$day) = Today();

The date comes back from the Today method as an array of three elements: year, month, and day. We read these elements directly into the appropriately-named variables.

Now that we have today’s date, we need to retrieve the date that is input on the command line. We do that with this line:

$ddate = @ARGV[0];

If you were diligently reading the Scripting Games Tips, you know that this is how you read arguments from the command line. Arguments are stored in the ARGV array – the first argument being index 0 of the array. We read this argument into the $ddate variable to retrieve the date that is input at the command line.

In the event description we noted we would be using U.S. date formats. Because of that we were able to use the Decode_Date_US function to turn the command-line argument from a string into a date:

($inyear,$inmonth,$inday) = Decode_Date_US($ddate);

We pass Decode_Date_US the date string ($ddate) we read in from the command line, and Decode_Date_US outputs the date formatted as year, month, and day.

Note: We initially didn’t know about the Decode_Date_US function. Instead we took a roundabout way of breaking down the input string to get the year, month and day. Because we went to the trouble to figure it out, we’ll show you that at the end after we finish explaining the rest of the script.

We now have the current date stored in the $year, $month, and $day variables, and the input date stored in the $inyear, $inmonth, and $inday variables. That means we’re ready to complete the first task in this event, which is to calculate the number of days between the two dates. We do that by calling the Delta_Days method:

$Ddays = Delta_Days($year,$month,$day, $inyear,$inmonth,$inday);

The first three parameters to the Delta_Days method are the year, month, and day of the earlier date; the last three parameters are the year, month, and day of the later date. All we need to do is display the output:

print "Days: $Ddays\n";

Okay, that’s step one. That really wasn’t too bad, right? Are you ready to move on to calculating the number of months between the two dates? All right, here we go.

The Date::Calc module provides another function that can help us out with this event, the Delta_YMD function:

@YMD = Delta_YMD($year,$month,$day, $inyear,$inmonth,$inday);

Delta_YMD takes the exact same parameters as Delta_Days: the year, month and day of the two dates you want to compare. The output is an array containing the difference between the two dates in years, months, and days, provided in an array (which we’ve named @YMD).

Note: Could we have done the same here as we did with the Today function and assign the output to individual variables, like this:

($dyear, $dmonth, $dday) = Delta_YMD($year,$month,$day, $inyear,$inmonth,$inday);

Yes. Why didn’t we? Oh, just because.

Figuring out the number of months between the two dates isn’t really difficult at this point; it just takes a simple calculation on our part:

$mo = ($YMD[0] * 12) + $YMD[1];

Here we’re taking the first item in the @YMD array, which is the difference between the two dates in years, and multiplying that by 12 (12 months per year, remember?). We then add that to the second item in the array, which holds the difference in months. For example, if today’s date is February 20, 2008 and we input March 3, 2009, the input date is 13 months from today. Delta_YMD will come return 1 year and 1 month (we don’t care about days at this point). We then multiply 1 (number of years) by 12 (for those of you who are really bad at math, that’s 12) and add 1. And there we have it: 13 months. We’re now done calculating the number of months; according to the event instructions we need to display that:

print "Months: $mo\n";

The last part of this event was to determine the number of months plus days between the two dates. Let’s look at an example again: Today is February 20, 2008 and our input date is March 3, 2008. The number of months between March 2008 and February 2008 is 1. However, there isn’t a full month between the two, there are actually only 12 days. So while our Month result will be 1 month, our month plus days output would be this:

Month/Day: 0 / 12

What this means is that we need to go back in time a little bit if the day of our input date is less than today’s day. You know, like this:

if ($inday < $day)
{
    $mo = $mo - 1;
}

All we’re doing here is checking to see if the day we input ($inday) is less than (<) today’s day ($day). If it is, we subtract 1 from our month result.

The next thing we do is call the Add_Delta_YM function to add this month result to today’s date:

($tyear,$tmonth,$tday) =
      Add_Delta_YM($year,$month,$day, 0,$mo);

This puts today’s date within less than one month of the input date. For example, if today is February 20, 2008 and the input date is April 3, 2008, $mo will be equal to 1. Using the Add_Delta_YM function we add 1 to today’s date to get March 20, 2008. (Notice that this function adds months and years. We don’t want to add any years; the number of months will make up for any difference in years. So we simply pass a 0 for the year parameter.)

Now we can go back to a familiar function, the Delta_Days function, to find the difference between these two dates:

$xdays = Delta_Days($tyear,$tmonth,$tday, $inyear,$inmonth,$inday);

And that’s it. All we have to do is display the Months/Days information:

print "Month/Day: $mo / $xdays";

If today is February 20, 2008 and we input February 21, 2009, here are our results:

Days: 367
Months: 12
Month/Day: 12 / 1
Top of pageTop of page

Parsing the Date

We mentioned earlier that we used the Decode_Date_US function to turn a date string into a year, month, and day. Here’s what we did before we discovered (with a little help) this function:

@indate = split(/ /, $ddate);

@months = qw(January February March April May June July 
             August September October November December);

$i = 0;

for $m (@months)
{
    $i++;
    if ($m eq $indate[0])
    {
        last;
    }
}
$inmonth = $i;
@getday = split(/,/,@indate[1]);
$inday = @getday[0];
$inyear = @indate[2];

We said in the event description that dates would be entered on the command line like this: “March 3, 2008”. The interesting thing about Perl is that is has absolutely no idea that a string like that is a date. That means we have to go through a bit of trouble to turn a date string into something Perl will recognize as a date. We start by splitting our string into an array:

@indate = split(/ /, $ddate);

We call the split function, passing it the character to split on (in this case a blank space) and the string we want to split ($ddate). We assign the results to the array @indate. If the date we input at the command line was March 3, 2008, our array looks like this:

IndexValue

0

March

1

3,

2

2008

The first problem we have here is the month. Perl doesn’t recognize month strings, only month numbers. That means we have to turn “March” into 3. We start by creating another array:

@months = qw(January February March April May June July 
             August September October November December);

This line of code creates an array named @months. Normally when you pass strings to an array in Perl you need to put quotes around each string, like this:

@a = ('apple' 'berry' 'cherry');

If we want to make you code look a little cleaner, instead of putting quotes around each string you can simply pass all the values to the qw function (qw stands for quote word). The qw function will put quotes around all the array elements. We like the idea of not having to put all those quotes in, so we used the qw function.

Next we initialize a counter ($i) that we’ll use to keep track of the month number as we read through the array of month names:

$i = 0;

for $m (@months)

We use a for loop (actually a foreach loop, but in Perl you can abbreviate this as for) to read through the array of months one at a time. Each time through the loop we increment the value of $i by one:

$i++;

In Perl, two plus signs following a variable is shorthand for “add 1 to the variable.” So $i++ is the same as $i = $i + 1.

Recall that the month we input at the command line is stored in the first index position of our @indate array. That means we can find the number associated with that month by checking to see if the date in our array of months is equal to (eq) the month in our @indate array:

if ($m eq $indate[0])

If they’re the same, $i will be equal to the associated month number and we can exit the for loop:

last;

If they’re not the same we simply loop around and check the next month in the array. When we’re done, we assign the value of $i to the variable representing the input month:

$inmonth = $i;

Now we need to retrieve the day. Again, going back to our input array, the second element in that array contained the day:

3,

You’ve probably noticed there’s a bit of a problem with this day. That’s right, there’s a comma in it. We need to strip out the comma to retrieve only the day. We do that by calling the split function again, this time splitting on the comma:

@getday = split(/,/,@indate[1]);

After splitting on the comma, we’ll be left with a one-item array. That item will contain the day. So we assign the first (and only) item of the array to the variable holding our input day:

$inday = @getday[0];

Fortunately the final item in our input date array (at index position 2) contains the year; that means that we don’t need to do anything to the year except assign it to the input year variable:

$inyear = @indate[2];

And that’s the hard way to parse a date.

Top of pageTop of page

From the Experts

There was a point at which we still held out some hope that we were just missing something in Perl, and that all this could be done without having to install the Date::Calc module. So we went to an expert in Perl, Jan Dubois from ActiveState. (See his solutions to the Advanced Division events here.) Jan confirmed that Date::Calc was the way to go. However, he did show us an example that cleaned up our solution a bit to make it a little more concise and more “Perl-like”:

use strict;
use warnings;

use Date::Calc qw(:all);

my @indate = Decode_Date_US(shift);
my @today = Today();

my $delta_days = Delta_Days(@today, @indate);

print "Days: $delta_days\n";

my @delta_ymd = Delta_YMD(@today, @indate); my $mo = ($delta_ymd[0] * 12) + $delta_ymd[1];

print "Months: $mo\n";

--$mo if $indate[2] < $today[2];

my @t = Add_Delta_YM(@today, 0, $mo);
my $xdays = Delta_Days(@t, @indate);

print "Month/Day: $mo / $xdays";

He started with the fact that including use strict and use warnings can come in pretty handy, especially for beginners. These statements will give you warning messages if your code has some problems that might be better to fix up.

One major difference in this version is the way in which the command-line argument is read in:

my @indate = Decode_Date_US(shift);

Here we’ve used the shift method to retrieve the command-line argument. As Jan explains: “shift() at the file level will remove and return the first element of @ARGV. (At the function level it operates on @_, not @ARGV).” So calling shift means we don’t have to use an array for just one argument. Also notice that the call to shift is included as the parameter to Decode_Date_US, saving us a line of code and an extra variable.

The rest of the script does pretty much the same thing as the original, just a little more concisely.


Top of pageTop of page