运行时动态选择方法;访问者模式或反射的替代方案

6

我正在制作一个小游戏模板,世界由节点组成,如下所示:

World
|--Zone
|----Cell
|------Actor
|------Actor
|--------Item

一个World可以包含多个Zone对象,一个Zone可以包含多个Cell对象,以此类推。

每个对象都实现了Node接口,该接口有一些方法,如getParentgetChildrenupdatereset等。

我想能够在单个节点上或从节点(由Task指定)递归地向下执行给定的Task

此外,我希望这是一个“可插拔”的系统,即希望玩家/开发人员能够随时添加新类型到树中。我也考虑过从基本类型进行转换:

public void doTask(Actor node)
{
    if(!(node instanceof Goblin)) { return; }
    Goblin goblin = (Goblin) node;
}

起初,我想使用访问者模式来利用双重分派,允许每个例程(访问者)根据被访问的Node类型进行操作。但是,这引起了一些复杂性,特别是当我想向树中添加新的Node类型时。
作为替代,我编写了一个实用类,该类使用反射查找最适用于Node的最具体方法。 现在我的担忧是性能; 由于将会有相当多的反射查找和调用,我担心我的游戏的性能(每秒可能有数百或数千次这些调用)会受到影响。
这似乎解决了两种模式的问题,但使得每个新Task的代码更加丑陋。

在我看来,我有三种选择来允许这种动态分发(除非我漏掉了一些明显/晦涩的东西,这就是为什么我来这里的原因):

访问者模式
优点: - 双重分派 - 性能 - 任务中的清晰代码
缺点: - 难以添加新的节点类型(在不修改原始代码的情况下不可能) - 在调用任务时的丑陋代码
使用反射进行动态调用
优点: - 可以随意添加新的节点类型 - 任务非常可定制 - 任务中的清晰代码
缺点: - 性能较差 - 在调用任务时的丑陋代码
强制转换
优点: - 比反射更具性能 - 可能比访问者更加动态 - 调用任务时的清晰代码
缺点: - 代码有异味 - 比访问者性能低(没有双重分派,在每次调用中进行强制转换) - 任务中的丑陋代码

我是否忽略了一些显而易见的东西?我熟悉许多四人帮设计模式,以及游戏编程模式中的模式。在这里任何帮助都将不胜感激。

明确一下,我不是在问哪种方法是“最好”的。我正在寻找这些方法的替代方案。


你为什么认为反射不高效? - Software Engineer
1
你为什么认为它是这样的呢?尤其是在像游戏中的世界系统这样需要每秒更新多次的任务中,反射会极大地影响性能。 - Clashsoft
@Constantin 接口中有一个 getTasks() 方法,它会被类似 node.getTasks().forEach(t - > t.doTask(node)) 的代码调用。但是在运行时,节点的类型是未知的,这就是为什么要使用反射来选择特定类型的节点所调用的 doTask 方法。Task 接口可以接受多种类型,但如果使用该库的开发人员添加了新的节点类型,则它不会成为接口的一部分。 - Caleb Brinkman
我不明白为什么在运行时需要知道节点的类型,只要通过接口方法以多态方式对其进行操作,如果它不是接口的一部分,则使用包装器或适配器模式。 - Constantin
一个静态的面向对象结构将与您的可插拔性要求相冲突。(更不用说您的层次结构似乎没有遵守“是一个”的要求。一个“Item”真的是一个“World”吗?)如果您希望这个结构真正具有运行时动态性,您将被迫将数据模型(节点)与行为(任务)解耦。一旦数据和行为被解耦,您基本上就是在进行消息传递(这就是我建议使用akka的原因)。 - Brian Kent
显示剩余9条评论
4个回答

4

在研究了Java 8 Lambdas及其如何通过反射进行构造后,我想到了一个创新的思路:使用从反射获得的 Method 对象来创建一个 BiConsumer,其中第一个参数是应调用该方法的实例,而第二个参数则是该方法的实际参数:

private static <T, U> BiConsumer<T, U> createConsumer(Method method) throws Throwable {
    BiConsumer<T, U> consumer = null;
    final MethodHandles.Lookup caller = MethodHandles.lookup();
    final MethodType biConsumerType = MethodType.methodType(BiConsumer.class);
    final MethodHandle handle = caller.unreflect(method);
    final MethodType type = handle.type();

    CallSite callSite = LambdaMetafactory.metafactory(
          caller,
          "accept",
          biConsumerType,
          type.changeParameterType(0, Object.class).changeParameterType(1, Object.class),
          handle,
          type
    );
    MethodHandle factory = callSite.getTarget();
    try {
        //noinspection unchecked // This is manually checked with exception handling.
        consumer = (BiConsumer<T,U>) factory.invoke();
    }catch (ClassCastException e) {
        LOGGER.log(Level.WARNING, "Unable to cast to BiConsumer<T,U>", e);
    }
    return consumer;
}

一旦创建了这个BiConsumer,它就会被缓存到一个HashMap中,使用参数类型和方法名作为键。然后可以像这样调用它:

consumer.accept(nodeTask, node);

该调用方法几乎完全消除了反射所需的调用开销,但它确实具有一些问题/限制:
- 由于使用了 BiConsumer,只能将一个参数传递到该方法中(accept 方法的第一个参数必须是应在其上调用方法的实例)。
- 对于我的目的来说,这很好,因为我只想传递一个参数。
- 当使用以前从未见过的参数类型调用方法时,会存在非常显著的性能开销,因为必须先进行反射搜索。
- 对于我的目的来说,这也没关系;可接受节点类型的数量不会很大,并且随着它们的出现而迅速缓存。在找到参数类型组合的适当方法后,后续的性能开销就非常小了(我认为是恒定的,因为它只是一个简单的 HashMap 查找)。
- 需要 Java 8(我已经在使用了)
我可以通过使用自定义函数接口(类似于 Invoker 类,而不是 Java 的 BiConsumer)来澄清此代码,但目前它能够以我想要的性能达到我想要的效果。

为什么在出现ClassCastException时返回null? - jaco0646
这只是一个概念验证,以确保它能够按预期工作和执行,没有太多考虑错误处理 :-) - Caleb Brinkman

1
我认为如果你不能拥有一个静态工厂类,那么这是一个棘手的问题。如果允许使用静态工厂,则此简短示例可能会提供一些思路。
这种方法允许在运行时将INode实例插入到树(WorldNode)中,但它并没有回答如何创建这些具体的INodes。我希望您有某种工厂模式。
    import java.util.Vector;

    public class World {

      public static void main(String[] args) {
        INode worldNode = new WorldNode();
        INode zoneNode = new ZoneNode();

        zoneNode.addNode(new GoblinNode());
        zoneNode.addNode(new GoblinNode());
        zoneNode.addNode(new GoblinNode());
        zoneNode.addNode(new GoblinNode());
        worldNode.addNode(zoneNode);

        worldNode.addNode(new ZoneNode());
        worldNode.addNode(new ZoneNode());
        worldNode.addNode(new ZoneNode());

        worldNode.runTasks(null);
      }
    }

    interface INode {
      public void addNode(INode node);
      public void addTask(ITask node);
      public Vector<ITask> getTasks();
      public void runTasks(INode parent);
      public Vector<INode> getNodes();
    }

    interface ITask {
      public void execute();
    }

    abstract class Node implements INode {
      private Vector<INode> nodes = new Vector<INode>();
      private Vector<ITask> tasks = new Vector<ITask>();

      public void addNode(INode node) {
        nodes.add(node);
      }

      public void addTask(ITask task) {
        tasks.add(task);
      }

      public Vector<ITask> getTasks() {
        return tasks;
      }

      public Vector<INode> getNodes() {
        return nodes;
      }

      public void runTasks(INode parent) {
        for(ITask task : tasks) {
          task.execute();
        }
        for(INode node : nodes){
          node.runTasks(this);
        }
      }
    }

    class WorldNode extends Node {
      public WorldNode() {
        addTask(new WorldTask());
      }
    }

    class WorldTask implements ITask {
      @Override
      public void execute() {
        System.out.println("World Task");
      }
    }

    class ZoneNode extends Node {
      public ZoneNode() {
        addTask(new ZoneTask());
      }
    }

    class ZoneTask implements ITask {

      @Override
      public void execute() {
        System.out.println("Zone Task");
      }
    }

    class GoblinNode extends Node {
      public GoblinNode() {
        addTask(new GoblinTask());
      }
    }

    class GoblinTask implements ITask {

      @Override
      public void execute() {
        System.out.println("Goblin Task");
      }
    }

输出:

World Task
    Zone Task
        Goblin Task
        Goblin Task
        Goblin Task
        Goblin Task
Zone Task
Zone Task
Zone Task

1
反射的想法很好 - 你只需要根据参数类型缓存查找结果。
访问者模式可以由用户程序扩展。例如,给定访问者模式中经典的 NodeVisitor 定义,用户可以定义 MyNode, MyVisitor
interface MyVisitor extends Visitor
{
    void visit(MyNode m);
    void visit(MyNodeX x);
    ...
}

interface MyNode extends Node
{
    @Override default void accept(Visitor visitor)
    {
        if(visitor instanceof MyVisitor)
            acceptNew((MyVisitor) visitor);
        else
            acceptOld(visitor);
    }

    void acceptNew(MyVisitor visitor);
    void acceptOld(Visitor visitor);
}

class MyNodeX implements MyNode
{
    @Override public void acceptNew(MyVisitor visitor)
    {
        visitor.visit(this);
    }
    @Override public void acceptOld(Visitor visitor)
    {
        visitor.visit(this);
    }
}
// problematic if MyNodeX extends NodeX; requires more thinking

一般来说,我不喜欢访问者模式;它相当丑陋、僵硬和侵入性。


基本上,问题是给定一个节点类型和一个任务类型,查找处理程序。我们可以通过一个简单的映射(node,task)->handler来解决这个问题。我们可以为绑定/查找处理程序发明一些API。
register(NodeX.class, TaskY.class, (x,y)->
{ 
    ...  
});

或者使用匿名类
new Handler<NodeX, TaskY>()  // the constructor registers `this`
{
    @Override public void handle(NodeX x, TaskY y)
    ...

在节点上调用一个任务。
invoke(node, task);
// lookup a handler based on (node.class, task.class)
// if not found, lookup a handler on supertype(s). cache it by (node.class, task.class)

缓存方法并不能解决性能问题。反射开销是在调用时而不是查找时产生的。Java 8 MethodHandles声称开销是在查找时产生的,因此这可能是一种替代方案。 - CoronA
在OP的代码示例中,使用反射来查找要调用的正确方法有些复杂且耗费资源,并且每次调用都需要重复查找的过程。我建议缓存查找结果。 - ZhongYu

0

如果你在寻求性能方面的解决方案,访问者模式是一个不错的选择。我个人认为反射并不是一个解决方案,因为它似乎不真实且过于复杂。强制转换虽然可行,但在面向对象的环境中通常被视为代码异味,应该避免使用(例如使用访问者模式)。

另一个重要的方面是可读性可写性:访问者模式可能需要更多的工作量,并在添加节点时需要更多的维护,但它绝对更易于阅读和理解。反射在这两个方面都是不可取的,而强制转换也是代码异味


如果性能是我唯一的关注点,我会完全同意你的观点;不幸的是,能够向树中添加新类型非常重要,使用访问者模式非常困难(实际上对于不修改原始库的人来说是不可能的),因此如果可能的话,我真的宁愿避免使用它。反射本身在框架中是“隐藏”的;使用它的人实际上永远不必看到/使用反射代码,所以这不是那么重要的问题。 - Caleb Brinkman
我真正在寻找的是三种方法的替代方案,而不是对这三种方法的比较;我会编辑问题以反映这一点。 - Caleb Brinkman

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