One of Perl's strengths is that "There's more than one way to do it" (TMTOWTDI, often pronounced tim-toady). This makes for a language that is flexible, and allows you to write a solution to a problem that matches the problem, rather than trying to shoehorn the problem in to the language in order to solve it.
This is also one of Perl's weaknesses. There can be many ways to solve a particular problem -- some of them are not optimal.
One of those, that I've rolled countless home-grown solutions for over the years, is that of parameter validation and handling. When writing code (especially library code that's probably going to be called by code other than your own) it pays to be defensive. Catch and report errors as soon as possible and you can save yourself (and quite possibly someone else) a boatload of debugging time.
Perl's parameter passing is very flexible, adhering to the TMTOWTDI principle. Which also means there are lots of ways to carry out parameter validation. Here's the approach that I've settled on. I hope you find it useful too.
I'm lazy. I want to hand as much of the work off to someone else's code as possible. To that end I make use of two other Perl modules. Params::Validate and Exception::Class. Together, these two can do all the heavy lifting of detecting errors in the parameter list and reporting them in a coherent manner to the caller.
Consider a hypothetical module, called Example::Module. This module provides one function, which accepts a hashref, the keys of which specify parameter names, the values of which are the parameter values.
Here's how I'd start that module.
1 package Example::Module
2
3 use strict;
4 use warnings
5
6 use Params::Validate qw(:all);
7 use Exception::Class(
8 'Example::Module::X::Args'
9 => { alias => 'throw_args', },
10 );
11
12 Params::Validate::validation_options(
13 on_fail => sub { throw_args(error => shift) },
14 );
The first four lines declare the module and enable's Perl's strictness checks and related warnings. These are more or less boilerplate, and should be a standard part of any code written in Perl (with, perhaps, the exception of one liners written on the command line).
Line 6 loads Params::Validate, and imports the functions and constants that it defines in to the caller's (Example::Module) namespace.
Lines 7 through 10 are more interesting, doing a number of things.
The Exception::Class module is loaded. It's import() routine is called, and told to create a new package, Example::Module::X::Args. I use this convention frequently now -- exceptions are created in namespaces based on the main namespace, with an appended "::X", followed by the type of exception. In this case these exceptions relate to problems with the arguments passed to functions. The alias directive creates a new function, called throw_args(). If called, this function will throw an exception of the type Example::Module::X::Args, passing along any parameters it's given.
Lines 12 through 13 make use of this. Now that throw_args() has been created, Params::Validate::validation_options() is called. This provides an interface in to the inner workings of Params::Validate. This stanza configures Params::Validate to call the newly created throw_args() function is parameter validation fails. The error => shift code ensures that any error message created by Params::Validate is included in the exception.
That's all that you need to do in terms of set up. Now you just need to make sure that any function that requires parameter validation uses the Params::Validate functions appropriately.
Suppose we have one function in this module, frob(), that takes a hash ref of arguments:
1 sub frob {
2 my $args = validate(@_, {
3 foo = 1,
4 });
5
6 return 1;
7 }
That's enough to declare that foo is mandatory parameter. Now if you try and call that function without passing in that parameter your application dies with an appropriate message.
Example::Module::frob();
# Dies, printing "Mandatory parameter 'foo' missing in call to Example::Module::frob"
Example::Module::frob(foo => 1); # works
Exception::Class has other tricks up its sleeve. For example, instead of dying with a single error message you can have it generate a stack trace any time an exception is raised. By inserting this line before the call to validation_options:
Example::Module::X::Args->Trace(1)
this tracing facility is enabled.
Exception::Class can be used to easily throw other errors as well -- errors that it might make more sense to trap. Consider an application that retrieves and processes data from a database. Your Exception::Class import might look like this:
use Exception::Class(
'Example::Module::X::Args'
=> { alias => 'throw_args', },
'Example::Module::X::DB'
=> { alias => 'throw_db', },
);
Now, if your library needs to indicate that a database error has occured it can:
throw_db("Text of the error message");
and these errors will be distinguishable from errors due to incorrect arguments. More importantly, these errors are distinguished by the class in which they are defined, rather than the text of the error message -- distinguishing errors programmatically by the text of error messages is fragile, and prone to problems when messages are changed, either because the text has been reworded, or it has been translated.
You can take this further and produce class hierarchies of exceptions as necessary.
use Exception::Class(
'Example::Module::X::Args'
=> { alias => 'throw_args', },
'Example::Module::X::DB',
'Example::Module::X::DB::Connect'
=> { alias => 'throw_db_connect', },
'Example::Module::X::DB::Prepare'
=> { alias => 'throw_db_prepare', },
'Example::Module::X::DB::Query'
=> { alias => 'throw_db_query', },
);
This allows for distinguation of exceptions caused by argument parsing, database connections, query preparation, and query execution.
Edited to add:
You would do that like so:
# Use the 'isa' option to explicitly specify a class hierarchy
use Exception::Class(
'Example::Module::X::Args'
=> { alias => 'throw_args', },
'Example::Module::X::DB',
'Example::Module::X::DB::Connect'
=> { alias => 'throw_db_connect',
isa => 'Example::Module::X::DB', },
'Example::Module::X::DB::Prepare'
=> { alias => 'throw_db_prepare',
isa => 'Example::Module::X::DB', },
'Example::Module::X::DB::Query'
=> { alias => 'throw_db_query',
isa => 'Example::Module::X::DB', },
);
# Carry out some work. Trap any exceptions, and handle them
# at the end
eval {
my $db = connect_to_db(); # might throw ::X::DB::Connect
my $sql = get_sql_from_phrasebook(); # might call die()
my $q = prepare_query($db); # might throw ::X::DB::Prepare
my @results = query_db($q); # might throw ::X::DB::Query
do_something_with(@results);
}
# Handle any exceptions. If a ::DB::Query exception occurs then
# print one set of diagnostics. If any other ::DB exception occurs
# print another set of diagnostics. If any other exception occurs
# (e.g., ::Args, or the code died for some other reason) this is a
# "can't happen" situation, and the best thing to do is to die here
# with debugging information.
my $e;
if($e = Example::Module::X::DB::Query->caught()) {
print "There was an error querying the database\\n";
} elsif($e = Exception::Class::X::DB->caught()) {
print "There was an error working with the database\\n";
} elsif($e = Exception::Class->caught()) {
print "Something unexpected failed. The error was:\\n";
# rethrow the error
ref $e ? $e->rethrow() : die $e;
}
This has hoisted the error checking code out of the way of the code that does the actual work (which is in the eval block). This makes the functionality of this code clearer and easier to understand. The error handling logic is also in one place -- so if this sort of error handling is common throughout the application it can be abstracted away in to a separate function.
End of edits
Not every application is going to need this sort of exception hierarchy (SVN::Web, for example, has a single exception type, because any error basically means do the same thing; prepare an error message and show it to the user), but it's nice to know it's there.
Here's an example that you can cut/paste and play around with. It shows a second function, frob2(), which uses a few more of the Params::Validate features. It also has various commented lines, which you can uncomment as necessary, to experiment with the effects of the parameter validation and the exceptions that are thrown.
#!/usr/bin/perl
use strict;
use warnings;
package Example::Module;
use strict;
use warnings;
use Params::Validate qw(:all);
use Exception::Class(
'Example::Module::X::Args',
=> { alias => 'throw_args', },
);
# Change this to ->Trace(1) to enable stack traces for argument errors
Example::Module::X::Args->Trace(0);
Params::Validate::validation_options(
on_fail => sub { throw_args(error => shift) }
);
# Show basic parameter validation and exception throwing. The 'foo'
# key is mandatory, all other keys are forbidden.
sub frob {
my $args = validate(@_, {
foo => 1,
});
return $args->{foo};
}
sub frob2 {
my $args = validate(@_, {
foo => {
type => SCALAR,
default => 'The default',
},
});
return $args->{foo};
}
package main;
# Uncomment to see missed parameter handling
#print Example::Module::frob(), "\\n";
# Uncomment to see extra parameter handling
#print Example::Module::frob(for => 1, bar => 1), "\\n";
# Uncomment to see what happens when the params are correct
#print Example::Module::frob({foo => 'bar'}), "\\n";
# Uncomment to see parameters with defaults
#print Example::Module::frob2(), "\\n";
#print Example::Module::frob2({foo => 'bar'}), "\\n";
# Here's an example of exception handling. This section will never
# run if any of the previous calls (esp. to frob()) failed, because
# they were not wrapped in eval { }, and will have caused the program
# to abort. This error will not cause the program to abort, because
# it's wrapped in eval { }.
my $e;
eval {
print "Just before eval'd call to frob()\\n";
# Uncomment the next line to see how other errors are handled
#die "This is not an argument error\\n";
print Example::Module::frob(), "\\n";
print "Just after eval'd call to frob()\\n"; # won't print
};
if($e = Example::Module::X::Args->caught()) {
print "An argument error occured, but we're continuing\\n";
} elsif($e = Exception::Class->caught()) {
print "Something unexpected failed. The error was:\\n";
# rethrow the error
ref $e ? $e->rethrow() : die $e;
}
print "Made it to the end\\n";
This is good stuff, and I'm *so* glad you wrote it -- since now I don't have to. :-)
ReplyDeleteI just changed jobs, and the lucky person that gets to be the new me needed a primer on these two topics, using the very same modules. Thanks for saving me a few hours!
Perl parameter validation and error handling...
ReplyDeleteExamples of how to use Params::Validate and Exception::Class together to do sub/method parameter validation....
Exception::Class has a problem that it is not very clever telling the error file and line.
ReplyDeleteIf you make use methods $exception->file() and $exception->line() they give wrong location.
If you use instead
...
use Carp;
Params::Validate::validation_options(on_fail => sub { croak (shift)) });
Then you get the correct location for the error. (but then your are not using Exception::Class which is otherwise a good module).