Are you 21?

tags: dates  datetime 

I was going through a problem that used Perl’s DateTime to figure out a duration between today and someone’s birth date. If this number was 21 or greater, they can drink. If they aren’t, they can’t.

Relying on third-party modules is a problem. Most people look at a module and think “this saves me a lot of work”. They tend to not look back later and realize they are using 100 such modules and now are mostly either locked into old versions or accidentally supporting code they never wanted.

DateTime is amazing and exacting. You will get the right answer, even if leap seconds show up. If you are using it for several different things in your code, it might be worth it. However, if you are using it for one small thing that doesn’t care about time zones and leap seconds, you probably don’t need it. That was the case in this exercise.

The counterargument is that we can’t trust programmers to write correct code (and not correct in the Knuth sense). There is some point where the trade-off between local invention to third-party battle-tested code tips toward these external libraries. That’s not a clear line and probably depends on the people involved.

This is a pretty simple problem. Before I started coding (and granted, I’ve probably written this same code two or three times in my career), I wrote out the boundary conditions and chose an input format:

  • Say your birthday is 1970-07-05. Today is today, whatever that is. Are you 21?

  • Today is 2010-01-01. 2010 - 1970 is 30. You’re well clear of boundary issues, so you are over 21.

  • Today is 1992-01-01. 1992 - 1970 is 22. You’re well clear of boundary issues, so you are over 21.

  • What if it is 1991-08-05? 1991 - 1970 is 21, and it’s the month after your birth month, so you are over 21.

  • What if it is 1991-07-06? 1991 - 1970 is 21, it’s the month of your birth, and the day is greater than your birthday, so you are over 21.

  • What if it is 1991-07-05? 1991 - 1970 is 21, it’s the month of your birth, and the day is your birthday, so you are 21.

  • Everything else and you are below 21.

This leads to a few simple rules:

  • If the difference of the years is over 21, pass

  • If the difference of the years is less than 21, fail

  • If the difference of years is twenty one, check the month, then the day.

Given that, a programmer who’s trading money for work should be able to write that routine themselves and test it exhaustively. Here’s my solution, which is mostly testing:

use v5.20;

use Test2::V0;
use experimental qw(signatures);
no warnings 'redefine';

sub today;

my $birthday = '1970-07-05';

my @yes_years = qw(
	2010-01-01 1992-01-01 1991-08-05 1991-07-06 1991-07-05 );
foreach ( @yes_years ) {
	*today = sub { split /-/ };
	ok( am_21( $birthday ), "$_: you are 21" );
	}

my @no_years = qw( 1991-07-05 );
foreach ( @no_years ) {
	*today = sub { split /-/ };
	ok( am_21( $birthday ), "$_: you are not 21" );
	}

done_testing();


sub am_21 ( $birthday, $drinking_age = 21 ) {
	my( $year, $month, $day ) = today();
	my( $byear, $bmonth, $bday ) = split /-/, $birthday;

	( $year - $byear > $drinking_age ) or
	( $year - $byear == $drinking_age and (
		( $month > $bmonth ) or
		( $month == $bmonth and $day >=  $bday )
		)
	)
	}

Some notes:

  • No one cares about hours or time zones in this problem. If I was born in Alaska and wanted to drink in London (besides the minimum age difference), no barman is doing complex math and looking at the second hand.

  • am_21 gets exactly one date format and that format contains only the year, month, and day. It’s not its job to parse dates, so I don’t parse or reformat dates. If you need to parse dates, choose a level of specificity that makes sense across your application and write something to get all dates into that format so every part of the application is doing the same thing. Small tasks don’t make those decisions; they accept those decisions.

  • am_21 returns the answer, which is either yes or no. I don’t return some duration answer that I then have to inspect later. This has the consequence that the answer is only immediately useful. Within an hour, it might be different. But, should the answer be wrong in the future, it’s wrong in the safest way because the out-of-date answer is no instead of yes. A barkeep not serving is safer). (Read about “fail closed” and “fail open”—safety equipment that failures should fail into the state that is the most safe, not the state that is the least safe.).

  • The answer is Y or ( Y’ and ( M or ( M’ and D ) ) ), but this is a bit weird because Y’ is not not Y. A few times I’ve started to draw this as a switching network, but it’s not that simple.

Some things that you don’t need to do, but I did anyway:

  • I use Perl’s experimental subroutine signatures, introduced in v5.20.

  • I redefined today for each new date I wanted to test. Think of that as a mock. It’s not a big deal.

Some things I didn’t do, not because I decided against them but mostly because I’m not spending all day on this:

  • I split a string to get the year, month, and day. That should be the responsibility of something else. That exposes some details about the date format and tightly couples that format to the code. If I wanted to change how I deal with dates, I now have an island of code that is resistant to that change. And, I’ve likely forgotten that this code does date parsing on its own. But, this is a blog post I’m spending one hour on, so there it is.

  • This routine decides which starting date to use by calling today. A more general routine would allow me to specify the starting date. That would have been nice, but that’s not what I was thinking when I started. And that’s basically programming—you discover what you should have known earlier. I could make today the default argument for the starting date parameter.

  • I could have created a routine to return the age in years as a cardinal number, then let something else decide what to do with that number. Now it’s more general and useful for other things.

Some traps to watch out for:

  • Although I wasn’t more general with am_21 but could have been, I watch out for creeping generalization. At some point, I’ll lose the thread of who is responsible for what and get a framework complete with ORM and email capabilities when I really just wanted an age.