如何处理:Moo::Role 的 `before` 修饰符由于循环导入而被静默跳过?

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

使用 Moo::Role,我发现循环导入默默地阻止了我方法的 before 修饰符的执行。

我在 MyRole.pm 中有一个 Moo::Role :

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

...MyA.pm 的消费者:

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

..还有 MyB.pm 中的另一个:

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

当我 运行 这个 script.pl:

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

...我得到 The code should have died before this point at MyB.pm line 4. 但希望看到 This has been correctly executed at MyRole.pm line 5.

我认为这个问题是由循环导入引起的。如果我在 script.pl 中切换 use 语句的顺序,或者如果我将 MyRole.pm 中的 use MyB; 更改为 [=29 中的 require,它就会消失=].

这种行为是预期的吗?如果是这样,在无法避免循环导入的情况下,最好的处理方法是什么?

我可以解决这个问题,但感觉很容易无意中触发(特别是因为它会导致 before 函数,通常包含检查代码,被静默跳过)。

(我使用的是 Moo 版本 2.003004。显然 MyRole.pm 中的 use MyB; 在这里是多余的,但前提是我已经简化了这个重现示例的代码。)

循环导入可能会变得相当棘手,但行为始终如一。关键点是:

  1. use Some::Module 表现得像 BEGIN { require Some::Module; Some::Module->import }
  2. 加载模块时,将对其进行编译和执行。 BEGIN 块在解析周围代码期间执行。
  3. 每个模块只 require 一次。再次需要时,require忽略

知道这一点后,我们可以将您的四个文件组合成一个文件,该文件在 BEGIN 块中包含 required 个文件。

让我们从您的主文件开始:

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

我们可以将 use 转换为 BEGIN { require ... } 并包含 MyA 内容。为清楚起见,我将忽略对 MyAMyB 的任何 ->import 调用,因为它们在这种情况下不相关。

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

with('MyRole')也做了一个require MyRole,我们可以明确的说:

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

所以让我们扩展一下:

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();

然后我们可以扩展 use MyB,同时将 MyB 的 with('MyRole') 扩展为 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();

MyB 中我们有一个 require MyRole,但该模块已被要求。因此,这没有任何作用。在执行期间,MyRole 仅包含以下内容:

package MyRole;
use Moo::Role;

所以这个角色是空的。此时 requires 'the_method'; before the_method => sub { ... } 尚未编译。

因此 MyB 构成了一个空角色,不影响 the_method


如何避免这种情况?在这些情况下避免 use 通常是有帮助的,因为这会在当前模块初始化之前中断解析。这会导致不直观的行为。

当您 use 的模块只是 class 并且不影响源代码的解析方式(例如通过导入子程序)时,您通常可以将要求推迟到 运行 时间。不仅仅是执行顶级代码的模块的 运行 时间,而是主应用程序的 运行 时间。这意味着将您的 require 粘贴到需要使用导入的 class 的子例程中。由于 require 仍然有一些开销,即使已经导入了所需的模块,您可以像 state $require_once = require Some::Module 一样保护要求。这样,require 就没有 运行 时间开销。

总的来说:您可以通过在模块的顶层代码中尽可能少地进行初始化来避免许多问题。宁愿懒惰并推迟初始化。另一方面,这种懒惰也会使您的系统更具动态性和可预测性:很难判断已经发生了什么初始化。

更一般地说,认真考虑您的设计。为什么需要这种循环依赖?您应该决定要么坚持高层代码依赖于低层代码的分层架构,要么使用低层代码依赖于高层接口的依赖倒置。将两者混合会导致一团糟(图表 A:这个问题)。

我确实了解某些数据模型必然具有共同递归 classes。在那种情况下,通过将相互依赖的 classes 放在一个文件中来手动整理顺序是最清楚的。