【问题标题】:How to avoid circular unit reference?如何避免循环单位引用?
【发布时间】:2010-11-20 01:48:19
【问题描述】:

想象以下两类国际象棋游戏:

TChessBoard = class
private
  FBoard : array [1..8, 1..8] of TChessPiece;
...
end;

TChessPiece = class abstract
public
   procedure GetMoveTargets (BoardPos : TPoint; Board : TChessBoard; MoveTargetList : TList <TPoint>);
...
end;

我希望在两个单独的单元 ChessBoard.pasChessPiece.pas 中定义这两个类。

如何避免我在这里遇到的循环单元引用(每个单元都需要在另一个单元的接口部分)?

【问题讨论】:

    标签: delphi project-organization circular-reference


    【解决方案1】:

    Delphi 单元没有“从根本上损坏”。它们的工作方式促进了编译器的惊人速度并促进了简洁的类设计。

    能够以 Prims/.NET 所允许的方式将类分布在单元上,这种方法可以说从根本上被破坏了,因为它允许开发人员忽略正确设计其框架的需要,从而促进了类的混乱组织,促进了强加任意代码结构规则,例如“每个单元一个类”,作为通用格言没有技术或组织价值。

    在这种情况下,我立即注意到由于这种循环引用困境而在类设计中出现了一种特殊性。

    也就是说,为什么一个作品曾经需要参考一块板子?

    如果一块棋子是从棋盘上取下来的,那么这样的参考就没有意义了,或者对于一个被移除的棋子有效的“移动目标”可能只是那些对该棋子有效的作为新游戏中的“起始位置”的那些?但我认为这只是对要求 GetMoveTargets 支持使用 NIL 板引用进行调用的案例的任意理由之外的任何理由。

    单个棋子在任何给定时间的特定位置是单个国际象棋游戏的属性,同样有效可能可能的移动 任何给定的棋子都取决于其他棋子在游戏中的位置。

    TChessPiece.GetMoveTargets 不需要了解当前游戏状态。这是 TChessGame 的职责。 TChessPiece 不需要参考游戏或棋盘来确定给定当前位置的有效移动目标。棋盘约束(8 个等级和文件)是域常量,而不是给定棋盘实例的属性。

    因此,需要一个 TChessGame 来封装知识,该知识结合了对棋盘、棋子和 - 至关重要的是 - 规则的认识,但棋盘和棋子不需要彼此了解或游戏。

    将与不同片段相关的规则放在片段类型本身的类中似乎很诱人,但恕我直言,这是一个错误,因为许多规则是基于与其他片段的交互,在某些情况下与特定片段的交互类型。这种“大局观”行为需要对整个游戏状态有一定程度的监督(阅读:概述),这在特定的棋子类别中是不合适的。

    例如TChessPawn 可以确定有效的移动目标是向前一格或两格,或者如果这些对角格中的任何一个被占用,则为对角向前一格。但是,如果棋子的移动使国王处于 CHECK 状态,则棋子根本无法移动。

    我会通过简单地允许 pawn 类指示所有可能的移动目标来解决这个问题 - 向前 1 或 2 个方格以及两个对角线前方方格。 TChessGame 然后通过参考这些移动目标的占用率和游戏状态来确定其中哪些是有效的。仅当兵在其本垒上时,才可能向前走 2 个方格,向前方格被占用阻止移动 = 无效目标,未占用的对角线方格促进移动,如果任何其他有效的移动暴露了国王,那么该移动也是无效的。

    同样,可能会将普遍适用的规则放在基础 TChessPiece 类中(例如,给定的移动是否暴露了国王?),但应用该规则需要了解整体游戏状态- 即其他棋子的放置 - 所以它更恰当地属于 TCessGame 类的一般行为,恕我直言

    除了移动目标之外,棋子还需要指明 CaptureTargets,这在大多数棋子的情况下是相同的,但在某些情况下完全不同 - pawn 就是一个很好的例子。但同样,所有潜在捕获中的任何一个 - 如果有的话 - 对于任何给定的动作都是有效的 - 恕我直言 - 对游戏规则的评估,而不是一块或一类棋子的行为。

    与 99% 的此类情况 (ime - ymmv) 一样,通过更改类设计以更好地表示正在建模的问题,而不是找到将类设计硬塞到任意文件中的方法,或许可以更好地解决困境组织。

    【讨论】:

    • 编译器DOES在处理表单时每个单元强制一个类。因此,您必须跳过箍以允许两种形式合作。通常这意味着它应该是一种形式,但有时您需要独立移动的部分。
    • @Loren:编译器不会强制执行任何此类操作。 VCL 属性流机制与链接器和表单设计器合用,强制每个单元有一个 FORM 类,但您可以像在任何其他单元中一样在表单单元中引入额外的非表单类。无论如何,可以独立移动的表单“片段”应该是组件,如果您想要一个视觉设计表面来创建这些组件,您可以使用框架来创建这些片段。但是,是的,每帧(即表格)单元只有一个“片”。
    • 编译速度的论点直到 15 年前才有意义。今天,我从来没有遇到过任何语言的编译速度问题。只有 Delphi 开发人员似乎愿意放弃灵活性来获得每次编译几毫秒的时间。从我在现实世界中看到的大部分情况来看,这种限制根本不会促进干净的 OO 设计。它只是迫使人们制作包含 2934938 个类的巨大 .pas 文件,这些类都可以互相看到,甚至可以访问彼此的私有成员。
    • 你是对的。 Delphi 的单元文件没有“损坏”,但是您断言“每个单元/文件一个类”没有技术或组织优点是有缺陷的。它的优点在于理解的速度。作为开发人员,我们将 80%-90% 的时间用于阅读和理解源代码,而只有 10%-20% 的时间用于修改它。筛选文件中的 30 个边缘相关的类以仅修改其中的 1 个是浪费时间。 Delphi 的库被设计为每个文件有多个类,但编译器当然不会强迫任何人这样做。正如@Wouter 指出的那样,它只会鼓励紧密耦合。
    • 我并不是说应该盲目地坚持每个单元 1 个类。只是它具有您忽略的非常真实和重要的好处。除了可读性之外,它还减少了合并冲突的机会以及更改类时重新编译的需要。此外,大多数编译器消息仅报告文件名和行号。每个单元有 1 个类,甚至在您打开文件之前,哪个类有错误就很明显了。是的,规则总是有例外的。我只是认为每个单元 1 个类是规则而不是例外。
    【解决方案2】:

    一种解决方案可能是引入包含接口声明(IBoard 和 IPiece)的第三个单元。

    那么两个带有类声明的单元的接口部分可以通过其接口引用另一个类:

    TChessBoard = class(TInterfacedObject, IBoard)
    private
      FBoard : array [1..8, 1..8] of IPiece;
    ...
    end;
    

    TChessPiece = class abstract(TInterfacedObject, IPiece)
    public
       procedure GetMoveTargets (BoardPos: TPoint; const Board: IBoard; 
         MoveTargetList: TList <TPoint>);
    ...
    end;
    

    (GetMoveTargets 中的 const 修饰符避免了不必要的引用计数)

    【讨论】:

    • @user928177263 也许。但一旦学会,该解决方案可以应用于同一问题的更大变化
    【解决方案3】:

    将定义 TChessPiece 的单位更改为如下所示:

    TYPE
      tBaseChessBoard = class;
    
      TChessPiece = class
        procedure GetMoveTargets (BoardPos : TPoint; Board : TBaseChessBoard; ...    
      ...
      end;    
    

    然后将定义 TChessBoard 的单元修改为如下所示:

    USES
      unit_containing_tBaseChessboard;
    
    TYPE
      TChessBoard = class(tBaseChessBoard)
      private
        FBoard : array [1..8, 1..8] of TChessPiece;
      ...
      end;  
    

    这允许您将具体实例传递给棋子,而不必担心循环引用。由于棋盘私下使用 Tchesspiece,它实际上不必在 Tchesspiece 声明之前存在,只是作为占位符。 tChessPiece 必须知道的任何状态变量当然都应该放在 tBaseChessBoard 中,它们对两者都可用。

    【讨论】:

    • 接受了这个,因为它很短并且为我的问题提供了一个解决方案。我对所有其他好的答案都投了赞成票。
    • 我收到“E2086 Type 'tBaseChessBoard' is not yet fully defined”错误。
    • @Alaun,这不是一个完整的工作片段,只是足以显示解决方案的路径。 tBaseChessBoard 应该有 GetMoveTargets 需要但实现为抽象或虚拟的方法。
    【解决方案4】:

    最好将 ChessPiece 类移到 ChessBoard 单元中。
    如果由于某种原因您不能,请尝试将一个 uses 子句放在一个单元中的实现部分,而将另一个留在接口部分。

    【讨论】:

    • 关于您的第二点:正如我所提到的,这不起作用,因为我需要接口部分中其他单元的定义!
    • 哦,我错过了 :-) ChessPiece 真的有必要知道 ChessBoard,还是反过来?
    • 好吧,棋子应该决定它可以移动到哪里(不同的棋子会有子类),它需要知道棋盘以确定它可以去哪里。另一个方向很明确。
    • 也许你应该有 TChessController 来移动棋子?
    【解决方案5】:

    使用 Delphi Prism,您可以将命名空间分布在单独的文件中,这样您就可以以一种干净的方式解决它。

    当前的 Delphi 实现从根本上破坏了单元的工作方式。只需看看“db.pas”如何在一个可怕的 .pas 文件中包含 TField、TDataset、TParam 等,因为它们的接口相互引用。

    无论如何,您始终可以将代码移动到单独的文件中,并使用 {$include ChessBoard_impl.inc} 将它们包含在内。这样你就可以在文件中分割东西并通过你的 vcs 拥有单独的版本。但是,这样编辑文件有点不方便。

    最好的长期解决方案是敦促 embarcadero 放弃一些在 1970 年帕斯卡诞生时有意义的想法,但这对于当今的开发人员来说只不过是一种痛苦。一次性编译器就是其中之一。

    【讨论】:

    • 令人遗憾的是,人们只是大规模投票而不评论他们不同意的部分。
    • +1 建议一次性编译器应该是历史。我猜对于两遍编译器,编译时间的平均增加不会超过 5%。也许有人知道确切的数字:)?
    • 单元文件没有“损坏”。唯一阻止这些巨大的 .pas 文件被分离的是向后兼容性。大量使用接口可以避免这种耦合。我同意一次性编译器。它对开发人员提出了不必要的要求,并阻止了一些非常有用的运行时优化,这些优化需要多次通过才能完成。
    【解决方案6】:

    看起来 TChessBoard.FBoard 不需要是 TChessPiece 的数组,它也可以是 TObject 并在 ChessPiece.pas 中向下转换。

    【讨论】:

    • -1 不,它不能。 TChessBoard 中有一些方法可以调用,即 FBoards [I, J].GetMoveTargets (...)。除此之外,沮丧并不是我一直在寻找的干净解决方案。
    • 那你为什么在你的问题中不这么说呢?我只是给你一个简单的提示,不需要因为我不知道你的课程而对我投反对票。
    • 这不是我投反对票的原因。我投反对票的原因是我不认为一个低调的解决方案是一个干净的解决方案。但是由于我没有明确要求一个干净的面向对象的解决方案,所以我收回了我的反对意见。对此感到抱歉。
    【解决方案7】:

    另一种方法:

    制作您的 tBaseChessPiece 棋盘。它是抽象的,但包含您需要参考的定义。

    内部工作在 tChessPiece 中,它继承自 tBaseChessPiece。

    我同意 Delphi 对相互引用事物的处理很糟糕——这是该语言最糟糕的特性。我一直呼吁跨单位工作的前向声明。编译器将拥有它需要的信息,它不会破坏使其如此快速的一次性特性。

    【讨论】:

      【解决方案8】:

      这种方法怎么样:

      棋盘单位:

      TBaseChessPiece = class 
      
      public
      
         procedure GetMoveTargets (BoardPos : TPoint; Board : TChessBoard; MoveTargetList : TList <TPoint>); virtual; abstract;
      
      ...
      
      TChessBoard = class
      private
        FBoard : array [1..8, 1..8] of TChessPiece;
      
        procedure InitializePiecesWithDesiredClass;
      ...
      

      件数单位:

      TYourPiece = class TBaseChessPiece
      
      public 
      
         procedure GetMoveTargets (BoardPos : TPoint; Board : TChessBoard; MoveTargetList : TList <TPoint>);override;
      
      ...
      

      在这种方法中,棋盘单元将仅在实现部分中包含对棋盘单元的引用(由于实际上将创建对象的方法),棋盘单元将在接口中对棋盘单元进行引用。 如果我没记错的话,这可以轻松解决您的问题...

      【讨论】:

      • 是的,这与已经提出的仅交换类的解决方案几乎相同。顺便说一句:为什么不使用缩进使代码更具可读性?只需使用 4 个空格缩进
      【解决方案9】:

      从 TObject 导出 TChessBoard

      TChessBoard = 类(TObject)

      然后你可以声明 过程GetMoveTargets(BoardPos:TPoint;Board:TObject;MoveTargetList:TList);

      当你调用 proc 时,使用 SELF 作为 Board 对象(如果你从那里调用它),那么你可以引用它

      (棋盘为 TChessBoard)。并从中访问属性等。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2016-11-21
        • 1970-01-01
        • 2015-09-05
        • 1970-01-01
        • 1970-01-01
        • 2011-03-14
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多