Manage Boilerplate with Import::Base

Tags:

Originally posted as: Manage Boilerplate with Import::Base on blogs.perl.org.

Boilerplate is everything I hate about programming:

  • Doing the same thing more than once
  • Leaving clutter in every file
  • Making it harder to change things in the future
  • Eventually blindly copying without understanding (cargo-cult programming)

In an effort to reduce some of my boilerplate, I wrote Import::Base, a module to collect and import useful bundles of modules, removing the need for long lists of use ... lines everywhere.

As I've grown as a Perl programmer, I've added more and more to my standard preamble for all but the most trivial Perl scripts. use strict and use warnings are absolute requirements. I want to use modern Perl's features like say, state, and others, so I'll import a feature bundle with use feature ":5.10". If I'm working on things I don't plan to share the code on CPAN, I can go all the way to use experimental qw( signatures postfix_deref ).

For class modules, I need to use Moo, use Types::Standard, and more. For roles, I need to use Moo::Role instead of Moo. If the project uses Moose, I need to use Moose's version of those things instead of Moo's version (or, in the case of Type::Tiny, make sure to use a Moo/Moose agnostic pattern).

For tests, I have a lot more. use Test::More, use Test::Deep, and use Test::Differences, are my go-to comparison set. My best practices also include use File::Temp, which requires that I use File::Spec::Functions, and use FindBin so I can locate the t/share directory for ancillary test files.

For command-line scripts, I have use Pod::Usage::Return, use Getopt::Long qw( GetOptionsFromArray ), in addition to my standard boilerplate of strict, warnings, and features.

And every project I write has imports that are used in just about every module: YAML, JSON, Path::Tiny, and project-specific utility modules.

My standard solution was as simple and blunt as it could be: Copy and paste. Besides being a stupidly-lazy solution, it left me with a problem: How could I modify all my modules to use a new feature bundle? Should I brush up on my sed(1) or write a Perl one-liner? What happens when I want to use a different module with an equivalent API, like changing to use YAML::XS instead of YAML::PP? How can I make a new module quickly available to all my classes, or all my roles, or all my tests, or all my scripts?

All these questions boiled down to: If I copy/paste my boilerplate everywhere, what happens when my boilerplate changes? This is why I hate boilerplate.

With Import::Into, we have a way to remove a massive block of imports from our boilerplate. Using Import::Into, I wrote a simple class to manage my imports, and allow me to quickly create different bundles of imports to use in different situations: Import::Base.

With Import::Base, you build a list of imports in a module. When someone imports your module, they get all your imports. They can also subclass your module to add or remove what your module imports.

A common base module should probably include strict, warnings, and a feature set.

package My::Base;
use base 'Import::Base';

our @IMPORT_MODULES = (
    'strict',
    'warnings',
    feature => [qw( :5.14 )],
);

Now we can consume our base module by doing:

package My::Module;
use My::Base;

Which is equivalent to:

package My::Module;
use strict;
use warnings;
use feature qw( :5.14 );

Now when we want to change our feature set, we only need to edit one file!

In addition to a set of modules, we can also create optional bundles:

package My::Base;
use base 'Import::Base';

# Modules that will always be included
our @IMPORT_MODULES
    'strict',
    'warnings',
    feature => [qw( :5.14 )],
    experimental => [qw( signatures )],
);

# Named bundles to include
our %IMPORT_BUNDLES = (
    Class => [ 'Moo', 'Types::Standard' => [qw( :all )] ],
    Role => [ 'Moo::Role', 'Types::Standard' => [qw( :all )] ],
    Test => [qw( Test::More Test::Deep )],
);

Now we can choose one or more bundles to include:

# lib/MyClass.pm
use My::Base 'Class';

# t/mytest.t
use My::Base 'Test';

# t/lib/MyTest.pm
use My::Base 'Test', 'Class';

What makes Import::Base more useful than rolling your own with Import::Into is the granular control you can get on the consuming side. On a case-by-case basis, individual imports can be removed if they conflict with something in the module (a name collision, for example). Then, the offending module can be used directly.

package My::StrangeClass;
use My::Base 'Class', -exclude => [ 'Types::Standard' ];
use Types::Standard qw( Str );

Boilerplate is everything I hate about programming. With Import::Base, I can remove boilerplate and replace it with a single line describing what the module needs.

Comments