How to handle: Moo::Role's `before` modifier silently skipped due to circular imports?
Asked Answered
R

1

6

Using Moo::Role, I'm finding that circular imports are silently preventing the execution of the before modifier of my method.

I have a Moo::Role in MyRole.pm :

package MyRole;
use Moo::Role;
use MyB;
requires 'the_method';
before the_method => sub { die 'This has been correctly executed'; };
1;

...a consumer in MyA.pm :

package MyA;
use Moo;
with ( 'MyRole' );
sub the_method { die; }
1;

..and another in MyB.pm :

package MyB;
use Moo;
with ( 'MyRole' );
sub the_method { die 'The code should have died before this point'; }
1;

When I run this script.pl:

#!/usr/bin/env perl
package main;
use MyA;
use MyB;
MyB->new()->the_method();

...I get The code should have died before this point at MyB.pm line 4. but would expect to see This has been correctly executed at MyRole.pm line 5.

I think this problem is caused by the circular imports. It goes away if I switch the order of the use statements in script.pl or if I change the use MyB; in MyRole.pm to be a require within the_method.

Is this behaviour expected? If so, what is the best way to handle it where circular imports can't be avoided?

I can workaround the problem but it feels worryingly easy to inadvertently trigger (particularly since it causes before functions, which often contain checking code, to be silently skipped).

(I'm using Moo version 2.003004. Obviously the use MyB; in MyRole.pm is superfluous here but only after I've simplified the code for this repro example.)

Rustic answered 18/12, 2017 at 14:19 Comment(0)
K
3

Circular imports can get rather tricky, but behave consistently. The crucial points are:

  1. use Some::Module behaves like BEGIN { require Some::Module; Some::Module->import }
  2. When a module is loaded, it is compiled and executed. BEGIN blocks are executed during parsing of the surrounding code.
  3. Each module is only require'd once. When it is required again, that require is ignored.

Knowing that, we can combine your four files into a single file that includes the required files in a BEGIN block.

Let's start with your main file:

use MyA;
use MyB;
MyB->new()->the_method();

We can transform the use to BEGIN { require ... } and include the MyA contents. For clarity, I will ignore any ->import calls on MyA and MyB because they are not relevant in this case.

BEGIN { # use MyA;
  package MyA;
  use Moo;
  with ( 'MyRole' );
  sub the_method { die; }
}
BEGIN { # use MyB;
  require MyB;
}
MyB->new()->the_method();

The with('MyRole') also does a require MyRole, which we can make explicit:

  ...
  require MyRole;
  with( 'MyRole ');

So let's expand that:

BEGIN { # use MyA;
  package MyA;
  use Moo;
  { # require MyRole;
    package MyRole;
    use Moo::Role;
    use MyB;
    requires 'the_method';
    before the_method => sub { die 'This has been correctly executed'; };
  }
  with ( 'MyRole' );
  sub the_method { die; }
}
BEGIN { # use MyB;
  require MyB;
}
MyB->new()->the_method();

We can then expand the use MyB, also expanding MyB's with('MyRole') to a require:

BEGIN { # use MyA;
  package MyA;
  use Moo;
  { # require MyRole;
    package MyRole;
    use Moo::Role;
    BEGIN { # use MyB;
      package MyB;
      use Moo;
      require MyRole;
      with ( 'MyRole' );
      sub the_method { die 'The code should have died before this point'; }
    }
    requires 'the_method';
    before the_method => sub { die 'This has been correctly executed'; };
  }
  with ( 'MyRole' );
  sub the_method { die; }
}
BEGIN { # use MyB;
  require MyB;
}
MyB->new()->the_method();

Within MyB we have a require MyRole, but that module has already been required. Therefore, this doesn't do anything. At that point during the execution, MyRole only consists of this:

package MyRole;
use Moo::Role;

So the role is empty. The requires 'the_method'; before the_method => sub { ... } has not yet been compiled at that point.

As a consequence MyB composes an empty role, which does not affect the the_method.


How can this be avoided? It is often helpful to avoid a use in these cases because that interrupts parsing, before the current module has been initialized. This leads to unintuitive behaviour.

When the modules you use are just classes and do not affect how your source code is parsed (e.g. by importing subroutines), then you can often defer the require to run time. Not just the run time of the module where top-level code is executed, but to the run time of the main application. This means sticking your require into the subroutine that needs to use the imported class. Since a require still has some overhead even when the required module is already imported, you can guard the require like state $require_once = require Some::Module. That way, the require has no run-time overhead.

In general: you can avoid many problems by doing as little initialization as possible in the top-level code of your modules. Prefer being lazy and deferring that initialization. On the other hand, this laziness can also make your system more dynamic and less predictable: it is difficult to tell what initialization has already happened.

More generally, think hard about your design. Why is this circular dependency needed? You should decide to either stick to a layered architecture where high-level code depends on low-level code, or use dependency inversion where low-level code depends on high-level interfaces. Mixing both will result in a horrible tangled mess (exhibit A: this question).

I do understand that some data models necessarily feature co-recursive classes. In that case it can be clearest to sort out the order manually by placing the interdependent classes in a single file.

Kinesiology answered 20/12, 2017 at 11:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.