Managing Boilerplate with Import::Base
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.
This is a terrible idea really: any change to your bundle mean breaking backwards compatibility. This sort of breakage-at-a-distance can be hard to diagnose. To some extend you can deal with it using a versioning argument (like Modern::Perl does nowadays), but IMO it's still not worth it.
Could you give an example? As far as I know, everything in the bundle is scoped lexically, so it only affects my project, not other projects using my project. Other projects are allowed to use the included base bundles, sure, and yes, if the bundle changes, it could potentially affect anyone who uses it.
Ah. I think I forgot to mention how I manage bundles by giving every CPAN-style dist of at least medium size its own ::Base module, or if the main namespace has no other purpose, making that the base module: Statocles::Base, App::YAML::Filter::Base, Freyr::Base (not on CPAN yet).
For an internal $work project, we release a bunch of dists together, and test them together extensively. For that, we have a separate "Utilities" project, and a ::Base module in there. But, in there we also have bundles for both Moo and Moose, codifying existing practices, not establishing new ones.
I don't like the idea of bundling up a bunch of things, putting the bundles on CPAN, and having other distributions depend on it. I don't see the utility in having to look at another CPAN dist to find what modules are being imported, and as you mentioned, the possibility of breakage-at-a-distance. Making a base module is so easy, there's no point in making it a separate dist.
In any case, I will definitely add a note about this to the docs.
I want to make a Dist::Zilla plugin that will scan a lib dir for a module that extends Import::Base and add prereqs, since using Import::Base also breaks automatic prereq scanning. This might also help demonstrate how to use it effectively.
Hi, great post!.
For whatever reason I get a "used only once" error for both Import::Base::IMPORT_MODULES and Import::Base::IMPORT_MODULES.
Can add lines to the top of My::Base like this:
$Import::Base::IMPORT_BUNDLES if 0;
$Import::Base::IMPORT_MODULES if 0;
But fixing it in Import::Base would be better. I'm just wondering why nobody else has seen these warnings? Anyway, I added the grep clause to the following line in Import::Base to deal with it:
for my $pkg ( grep !/^Import::Base$/, reverse @{ mro::get_linear_isa( $class ) } ) {
Seems to work.
Thanks for the bug report.
The reason nobody has seen that is probably because the IMPORT_* API is relatively new. Most of my existing uses of Import::Base override the modules() method. The static IMPORT_* stuff was added to let me do some interesting stuff in the future, and reduce the boilerplate introduced by the bundles feature (every modules() method would need to copy 5-6 lines of code in order to work right).
I'll add a test case and push a new release.
I added a bunch of new tests specifically for warnings, but I can't seem to reproduce your issue. Could you open a ticket on the Import::Base issue tracker and include enough information to reproduce it?
Your note about $Import::Base::IMPORT_MODULES seems odd, because it uses @Import::Base::IMPORT_MODULES. It also uses lax references to get the right name, which shouldn't trigger the "once" warning (since that, I believe, happens at compile time).
If you can write a test case and include your patch from your comment, I'll happily accept that too.
@preaction I'm seeing the same thing. If I do a syntax check on a module that uses my custom base module, the warnings are generated. The same is not true if I check syntax on the base module itself.
I'm a bit busy at the moment with a project but I'll see what I can do to debug it, write a test case and send you a patch.
Bug filed: https://github.com/preaction/Import-Base/issues/20