如何在解耦对象之间强制执行约束?

4
注意 - 我已将原帖移至底部,因为我认为对于这个主题的新手来说仍然具有价值。下面直接跟着的是根据反馈尝试重写问题的内容。
完全删除的帖子
好的,我会尽量详细说明我的具体问题。我意识到我有点混淆了域逻辑和界面/表现逻辑,但老实说我不知道该如何分离它们。请耐心等待 :)
我正在编写一个应用程序,其中(除其他事项外)执行物流模拟以移动物品。基本思路是用户看到一个项目,类似于Visual Studio,在其中可以添加、删除、命名、组织、注释等各种对象,我将要概述这些对象:
  • Items and Locations are basic behaviourless data items.

    class Item { ... }
    
    class Location { ... }
    
  • A WorldState is a Collection of item-location pairs. A WorldState is mutable: The user is able to add and remove items, or change their location.

    class WorldState : ICollection<Tuple<Item,Location>> { }
    
  • A Plan represents the movement of items to different locations at desired times. These can either be imported into the Project or generated within the program. It references a WorldState to get the initial location of various objects. A Plan is also mutable.

    class Plan : IList<Tuple<Item,Location,DateTime>>
    {
       WorldState StartState { get; }
    }
    
  • A Simulation then executes a Plan. It encapsulates a lot of rather complex behaviour, and other objects, but the end result is a SimulationResult which is a set of metrics that basically describe how much this cost and how well the Plan was fulfilled (think the Project Triangle)

    class Simulation 
    {
       public SimulationResult Execute(Plan plan);
    }
    
    class SimulationResult
    {
       public Plan Plan { get; }
    }
    
基本思想是用户可以创建这些对象,将它们连接起来,并可能重复使用它们。多个 Plan 对象可以使用一个 WorldState。然后可以在多个 Plans 上运行模拟。
冒着过于冗长的风险,以下是一个示例。
var bicycle = new Item();
var surfboard = new Item();
var football = new Item();
var hat = new Item();

var myHouse = new Location();
var theBeach = new Location();
var thePark = new Location();

var stuffAtMyHouse = new WorldState( new Dictionary<Item, Location>() {
    { hat, myHouse },
    { bicycle, myHouse },
    { surfboard, myHouse },
    { football, myHouse },
};

var gotoTheBeach = new Plan(StartState: stuffAtMyHouse , Plan : new [] { 
    new [] { surfboard, theBeach, 1/1/2010 10AM }, // go surfing
    new [] { surfboard, myHouse, 1/1/2010 5PM }, // come home
});

var gotoThePark = new Plan(StartState: stuffAtMyHouse , Plan : new [] { 
    new [] { football, thePark, 1/1/2010 10AM }, // play footy in the park
    new [] { football, myHouse, 1/1/2010 5PM }, // come home
});

var bigDayOut = new Plan(StartState: stuffAtMyHouse , Plan : new [] { 
    new [] { bicycle, theBeach, 1/1/2010 10AM },  // cycle to the beach to go surfing
    new [] { surfboard, theBeach, 1/1/2010 10AM },  
    new [] { bicycle, thePark, 1/1/2010 1PM },  // stop by park on way home
    new [] { surfboard, thePark, 1/1/2010 1PM },
    new [] { bicycle, myHouse, 1/1/2010 1PM },  // head home
    new [] { surfboard, myHouse, 1/1/2010 1PM },

});

var s1 = new Simulation(...);
var s2 = new Simulation(...);
var s3 = new Simulation(...);

IEnumerable<SimulationResult> results = 
    from simulation in new[] {s1, s2}
    from plan in new[] {gotoTheBeach, gotoThePark, bigDayOut}
    select simulation.Execute(plan);

问题在于执行类似以下代码时:
stuffAtMyHouse.RemoveItem(hat); // this is fine
stuffAtMyHouse.RemoveItem(bicycle); // BAD! bicycle is used in bigDayOut, 

基本上,当用户尝试通过world.RemoveItem(item)调用从WorldState(也许是整个项目)中删除一个项目时,我希望确保该项目不在任何使用该WorldState的Plan对象中被引用。如果是这样,我想告诉用户“嘿!以下计划X正在使用此项!在尝试删除它之前去处理一下它!”我不希望从world.RemoveItem(item)调用中出现以下行为:

  • 删除该项目但仍然让计划引用它。
  • 删除该项目但使计划默默地删除其列表中所有引用该项目的元素。(实际上,这可能是可取的,但只作为次要选项。)
我的问题基本上是如何以一种干净脱离的方式实现这种期望的行为。我考虑将其作为用户界面的职责(所以当用户在一个项目上按下'del'时,它会触发对计划对象的扫描并执行检查,然后调用world.RemoveItem(item)) - 但是(a)我也允许用户编写和执行自定义脚本,因此他们可以自己调用world.RemoveItem(item),(b)我不确定这种行为是否纯粹是"用户界面"问题。
哇,希望有人还在阅读......
原帖标题:假设我有以下类:
public class Starport
{
    public string Name { get; set; }
    public double MaximumShipSize { get; set; }
}

public class Spaceship
{
    public readonly double Size;
    public Starport Home;
}

假设存在一个约束条件,即飞船的大小必须小于或等于其所属星球的最大飞船尺寸。
那么我们该如何处理呢?
传统上,我会像这样耦合地处理:
partial class Starport
{
    public HashSet<Spaceship> ShipsCallingMeHome; // assume this gets maintained properly
    private double _maximumShipSize;
    public double MaximumShipSize
    {
        get { return _maximumShipSize; } 
        set
        {
            if (value == _maximumShipSize) return;
            foreach (var ship in ShipsCallingMeHome)
                if (value > ship)
                    throw new ArgumentException();
            _maximumShipSize = value
        }
    }
}

这在简单的示例中是可以管理的(因此可能是一个糟糕的示例),但我发现随着约束变得越来越大且更加复杂,以及我希望有更多相关功能(例如实现一个方法 bool CanChangeMaximumShipSizeTo(double) 或其他收集太大的船只的方法),我最终编写了更多不必要的双向关系(在这种情况下,SpaceBase-Spaceship被认为是适当的),并且编写了复杂的代码,它在所有者方程式中基本上是无关紧要的。
那么,这种情况通常如何处理? 我考虑过以下几点:
  1. 我考虑使用事件,类似于ComponentModel INotifyPropertyChanging / PropertyChanging模式,但EventArgs将具有某种Veto()或Error()功能(就像winforms允许您使用密钥或禁止表单退出一样)。 但是我不确定这是否构成了事件滥用。

  2. 或者,通过显式定义的接口自己管理事件,例如

asdf我需要在这里插入此行,否则格式不正确。

interface IStarportInterceptor
{
    bool RequestChangeMaximumShipSize(double newValue);
    void NotifyChangeMaximumShipSize(double newValue);
}

partial class Starport
{
    public HashSet<ISpacebaseInterceptor> interceptors; // assume this gets maintained properly
    private double _maximumShipSize;
    public double MaximumShipSize
    {
        get { return _maximumShipSize; } 
        set
        {
            if (value == _maximumShipSize) return;
            foreach (var interceptor in interceptors)
                if (!RequestChangeMaximumShipSize(value))
                    throw new ArgumentException();
            _maximumShipSize = value;
            foreach (var interceptor in interceptors)
                NotifyChangeMaximumShipSize(value);
        }
    }
}

但我不确定这样是否更好。我也不确定用这种方式自己编写事件是否会有某些性能影响,或者是否有其他原因可以解决这个问题。

  1. 第三种选择可能是使用PostSharp或IoC/依赖注入容器进行非常古怪的AOP。但我还没有准备好走这条路。

  2. 上帝对象管理所有检查等-仅在stackoverflow搜索god object时,我就觉得这是错误和不好的

我的主要关注点是这似乎是一个相当明显的问题,并且我认为这应该是一个相当常见的问题,但我没有看到任何讨论(例如System.ComponentModel没有提供任何设施来否决PropertyChanging事件-对吗?);这让我担心我(再次)未能掌握耦合或(更糟糕的是)面向对象设计的一些基本概念。

评论?


在重新修订的帖子中,您定义了两次“Plan”,但没有“Simulation”类的定义...是否应该为“Simulation”添加一个def? - James King
感谢您的指出 - 我已经修正了这个错误。 - fostandy
5个回答

1

INotifyPropertyChanging 接口是为数据绑定而设计的,这就解释了为什么它没有你要寻找的功能。我建议尝试类似以下的方法:

interface ISpacebaseInterceptor<T>
{ 
    bool RequestChange(T newValue); 
    void NotifyChange(T newValue); 
} 

谢谢 - 这基本上就是我在2中建议的。我想知道的是,这是否被普遍认为是可接受的做法。 - fostandy
fostandy:是的,我只是复制了你的接口并使其通用化。 - Gabe

1

基于修改后的问题:

我认为WorldState类需要一个委托...而Plan将设置一个应该被调用以测试项目是否正在使用的方法。有点像:

delegate bool IsUsedDelegate(Item Item);

public class WorldState {

    public IsUsedDelegate CheckIsUsed;

    public bool RemoveItem(Item item) {

        if (CheckIsUsed != null) {
            foreach (IsUsedDelegate checkDelegate in CheckIsUsed.GetInvocationList()) {
                if (checkDelegate(item)) {
                    return false;  // or throw exception
                }
            }
        }

        //  Remove the item

        return true;
    }

}

然后,在计划构造函数中,设置要调用的委托。
public class plan {

    public plan(WorldState state) {
        state.IsUsedDelegate += CheckForItemUse;
    }

    public bool CheckForItemUse(Item item) {
         // Am I using it?
    }

}

当然,这只是一个草稿,午餐后我会尝试添加更多内容:)但你可以得到大致的想法。

(午餐后 :) 缺点是您必须依赖于Plan来设置委托...但是根本没有办法避免。 Item无法告诉它有多少引用,也无法控制自己的使用。

最好的方法是达成理解的合同... WorldState同意不删除正在被Plan使用的项目,而Plan同意告诉WorldState它正在使用该项目。 如果Plan没有履行合同,那么它可能会处于无效状态。 那就倒霉了,Plan,这就是你不遵守规则的下场。

你不使用事件的原因是因为你需要一个返回值。另一种选择是让WorldState公开一种添加类型为IPlan的'监听器'方法,其中IPlan定义了CheckItemForUse(Item item)。但你仍然必须依赖于Plan通知WorldState在删除物品之前询问。
我看到的一个巨大的空白是:在你的例子中,你创建的PlanstuffAtMyHouse没有关联。例如,你可以创建一个Plan带着你的狗去海滩,Plan会非常高兴(当然,你必须创建一个狗Item)。编辑:你是不是想将stuffAtMyHouse传递给Plan构造函数,而不是myHouse
因为它们没有联系,所以你目前不在乎从stuffAtMyHouse中移除自行车...因为你现在的意思是“我不在乎自行车从哪里开始,也不在乎它属于哪里,只要把它带到海滩就可以了”。但是我认为你的意思是“从我的房子里拿走我的自行车,然后去海滩。”Plan需要有一个起始的WorldState上下文。 简而言之:最好的解耦方法是让Plan选择在删除项目之前WorldState应查询哪个方法。
希望对你有所帮助, 詹姆斯
原始答案: 我不完全清楚你的目标是什么,也许这只是一个强制性的例子。一些可能性:
I. 强制执行最大船舶尺寸等方法,例如SpaceBase.Dock(myShip) 很简单...当调用SpaceBase时,它会跟踪大小并向试图停靠的船只抛出一个TooBigToDockException异常,如果太大。在这种情况下,实际上没有任何耦合...您不需要通知船只新的最大船只尺寸,因为管理最大船只尺寸不是船只的责任。
如果最大船只尺寸减小,您将强制船只解除停靠...同样,船只不需要知道新的最大尺寸(尽管可能适合告诉它现在漂浮在空间中的事件或接口)。船只对此决定没有发言权或否决权...基地已经决定它太大了,并将其驱逐出去。
你的怀疑是正确的...上帝对象通常是不好的;明确界定的职责使它们从设计中消失。
II. SpaceBase的可查询属性

如果你想让一艘船询问你是否太大无法停靠,你可以公开这个属性。再次强调,你并没有真正地耦合...你只是让船根据这个属性做出停靠或不停靠的决定。但基地不相信船如果太大就不会停靠...在调用 Dock() 时,基地仍然会检查并抛出异常。

检查停靠相关约束的责任完全落在基地身上。


III. 当信息对双方都必要时,才是真正的耦合

为了停靠,基地可能需要控制船只。这里适合使用一个接口,ISpaceShip,它可能有诸如 Rotate()MoveLeft()MoveRight() 等方法。

这里通过接口本身避免了耦合问题...每艘船都会以不同的方式实现Rotate()...基类并不关心,只要它能调用Rotate()并使船只原地旋转即可。如果船只不知道如何旋转,则可能会抛出NoSuchManeuverException异常,在这种情况下,基类将决定尝试其他方法或拒绝对接。对象之间进行通信,但它们没有超出接口(协议)的耦合,基类仍负责对接。


IV. MaxShipSize Setter验证

你提到如果调用者试图将MaxShipSize设置为小于当前停靠船只的大小,则会向其抛出异常。但我的问题是,谁在尝试设置MaxShipSize,并且为什么要这样做?MaxShipSize应该在构造函数中设置并且是不可变的,或者根据自然规则来设置大小,例如你不能将船只大小设置为小于其当前大小,因为在现实世界中,你可以扩展一个SpaceBase,但永远不会缩小它。

通过防止不合逻辑的更改,您可以使强制解除停靠和随之而来的通信失去意义。


我想表达的观点是,当你觉得代码变得过于复杂时,你几乎总是正确的,你应该首先考虑底层设计。在编码中,越少越好。当你谈论编写Veto()和Error()以及其他方法来“收集太大的船只”时,我担心代码将变成一个鲁伯·戈尔德堡机器。我认为分离职责和封装将消除你所经历的许多不必要的复杂性。

就像有管道问题的水槽...你可以放入各种弯曲和管道,但正确的解决方案通常是简单、直接和优雅的。

希望对你有所帮助,
詹姆斯


主要问题是IV。我已经更新了我的帖子,试图提供一个更具体(并且希望是合法的)问题,欢迎您的评论。我确实同意我的代码变得不必要地复杂 - 问题是如何在不失去所需功能的情况下简化设计。 - fostandy
修改注释:你说得很对,我本意是将 stuffAtMyHouse 注入 Plan 中,并已做出了修改!我还没有完全消化你的新评论,但一旦我消化了,我会回复你 - 感谢你的建议! - fostandy

1

你想对操作应用约束,但是却在数据上应用它们。

首先,为什么允许更改Starport.MaximumShipSize?当我们“调整大小”Starport时,难道不应该所有的飞船都起飞吗?

这些都是为了更好地理解需要完成的任务而提出的问题(没有“对”或“错”的答案,只有“我的”和“你的”)。

从另一个角度看待这个问题:

public class Starport
{
    public string Name { get; protected set; }
    public double MaximumShipSize { get; protected set; }

    public AircarfDispatcher GetDispatcherOnDuty() {
        return new AircarfDispatcher(this); // It can be decoupled further, just example
    }
}

public class Spaceship
{
    public double Size { get; private set; };
    public Starport Home {get; protected set;};
}

public class AircarfDispatcher
{
    Startport readonly airBase;
    public AircarfDispatcher(Starport airBase) { this.airBase = airBase; }

    public bool CanLand(Spaceship ship) {
        if (ship.Size > airBase.MaximumShipSize)
            return false;
        return true;
    }

    public bool CanTakeOff(Spaceship ship) {
        return true;
    }

    public bool Land(Spaceship ship) {
        var canLand = CanLand(ship);
        if (!canLand)
            throw new ShipLandingException(airBase, this, ship, "Not allowed to land");
        // Do something with the capacity of Starport
    }

}


// Try to land my ship to the first available port
var ports = GetPorts();
var onDuty = ports.Select(p => p.GetDispatcherOnDuty())
    .Where(d => d.CanLand(myShip)).First();
onDuty.Land(myShip);

// try to resize! But NO we cannot do that (setter is protected)
// because it is not the responsibility of the Port, but a building company :)
ports.First().MaximumShipSize = ports.First().MaximumShipSize / 2.0

我已经大幅更新了问题/问题,如果有更多的意义(或者没有,无论哪种方式我猜),您的输入将不胜感激! - fostandy

1

你知道太空飞船必须有一个大小;将大小放在基类中,并在那里实现访问器的验证检查。

我知道这似乎过于关注你的具体实现,但这里的重点是你的期望并不像你想象的那么解耦;如果你在基类中对派生类中的某些内容有硬性期望,则你的基类正在对派生类提供该实现做出根本性期望;最好直接将该期望迁移到基类中,在那里你可以更好地管理约束。


看起来我给出了错误的印象。我将SpaceBase重命名为Starport,试图更清楚地表明Spaceship并不是从Starport派生而来的。 - fostandy
你甚至可以将“Size”放在接口中,而不是放在基类中。 - Joel

1
你可以像C++ STL traits类一样做一些事情 - 实现一个通用的SpaceBase<Ship, Traits>,它有两个参数化的Type - 一个定义了SpaceShip成员,另一个使用SpaceBaseTraits类来限制SpaceBase及其SpaceShip,以封装基地特征,例如它可以包含的船只的限制。

我不太明白 - 你能提供一个代码示例吗? - fostandy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接