特化中的参数类型协变

30

简述

在不支持泛型的编程语言(PHP)中,有什么策略可以克服特化参数类型不变性的问题?

注:我希望我对类型理论/安全性/变异等方面的理解更加完整;我不是CS专业。


情况

您有一个抽象类Consumer,希望对其进行扩展。 Consumer声明了一个需要定义的抽象方法consume(Argument $argument)。这应该不是问题。


问题

你的专门化Consumer,名为SpecializedConsumer,没有逻辑上与每种Argument一起工作的业务。相反,它应该接受一个SpecializedArgument(及其子类)。我们的方法签名更改为consume(SpecializedArgument $argument)

abstract class Argument { }

class SpecializedArgument extends Argument { }

abstract class Consumer { 
    abstract public function consume(Argument $argument);
}

class SpecializedConsumer extends Consumer {
    public function consume(SpecializedArgument $argument) {
        // i dun goofed.
    }
}

我们正在违反里氏替换原则,并导致类型安全问题。糟糕。


问题

好的,这方法行不通。然而,在这种情况下,有哪些模式或策略可以克服类型安全问题和违反LSP,但仍然保持 SpecializedConsumer Consumer 之间的类型关系?

我想一个可以接受的答案是:缩减到“你犯了错,回去重头开始”。


考虑事项、细节和勘误

  • 好的,一个立即的解决方案呈现出来,就是"不在Consumer中定义consume()方法"。好吧,这很有道理,因为方法声明只有签名那么好。语义上,即使有未知参数列表,缺少consume()也会让我的大脑受到伤害。也许有更好的方法。

  • 从我所读的内容来看,很少有语言支持参数类型协变;PHP是其中之一,并且是这里的实现语言。更加复杂的是,我看到了创造性的"解决方案",涉及泛型;另一个在PHP中不支持的特性。

  • 来自维基百科的协变(计算机科学)- 需要协变参数类型吗?:

    这在某些情况下会产生问题,其中参数类型应该是协变的,以模拟现实需求。假设你有一个表示人的类。一个人可以看医生,因此这个类可能有一个方法virtual void Person::see(Doctor d)。现在假设你想要制作一个Person类的子类Child。也就是说,一个Child是一个人。那么,我们可能想要制作一个Doctor的子类Pediatrician。如果儿童只看儿科医生,我们希望在类型系统中强制执行这一点。然而,一个天真的实现失败了:因为一个Child是一个Person,所以Child::see(d)必须接受任何Doctor,而不仅仅是Pediatrician

    文章继续说道:

    在这种情况下,访问者模式可以用来强制执行这种关系。在C++中解决问题的另一种方法是使用泛型编程

    同样,泛型可以创造性地用于解决问题。我正在探索访问者模式,因为我已经有了一个半成品的实现,但是大多数文章中描述的实现利用了方法重载,这也是PHP中不支持的另一个特性。


<太多信息>

实现

由于最近的讨论,我将扩展我未包含的具体实现细节(也就是说,我可能会包括过多的内容)。

为了简洁起见,我省略了那些(应该)在其目的上非常明确的方法体。我尝试保持简洁,但我往往会用很多话。我不想把一堵墙的代码倒出来,所以解释跟随/先于代码块。如果您有编辑权限,并想清理一下,请这样做。另外,代码块不是从项目中复制-粘贴的。如果某些东西不合理,可能就是这样;请指导我澄清。

关于原问题,此后 Rule 类是 Consumer,而 Adapter 类是 Argument

与树相关的类如下:

abstract class Rule {
    abstract public function evaluate(Adapter $adapter);
    abstract public function getAdapter(Wrapper $wrapper);
}

abstract class Node {
    protected $rules = [];
    protected $command;
    public function __construct(array $rules, $command) {
        $this->addEachRule($rules);
    }
    public function addRule(Rule $rule) { }
    public function addEachRule(array $rules) { }
    public function setCommand(Command $command) { }
    public function evaluateEachRule(Wrapper $wrapper) {
        // see below
    }
    abstract public function evaluate(Wrapper $wrapper);
}

class InnerNode extends Node {
    protected $nodes = [];
    public function __construct(array $rules, $command, array $nodes) {
        parent::__construct($rules, $command);
        $this->addEachNode($nodes);
    }
    public function addNode(Node $node) { }
    public function addEachNode(array $nodes) { }
    public function evaluateEachNode(Wrapper $wrapper) {
        // see below
    }
    public function evaluate(Wrapper $wrapper) {
        // see below
    }
}

class OuterNode extends Node {
    public function evaluate(Wrapper $wrapper) {
        // see below
    }
}

所以每个InnerNode包含RuleNode对象,而每个OuterNode只有Rule对象。 Node::evaluate()评估每个RuleNode::evaluateEachRule())为布尔值true。如果每个Rule都通过了,Node已经通过,并将其Command添加到Wrapper中,并将下降到子级进行评估(OuterNode::evaluateEachNode()),或者分别对于InnerNodeOuterNode对象返回true
关于Wrapper对象; Wrapper对象代理一个Request对象,并拥有一组Adapter对象。 Request对象是HTTP请求的表示。 Adapter对象是专门为特定Rule对象使用的接口(并维护特定状态)。(这就是LSP问题出现的地方)

Command对象是一个操作(实际上是一个整齐打包的回调函数),一旦完成所有操作,将其添加到Wrapper对象中,最终按顺序触发Command对象数组,传递Request (等其他内容)。
class Request { 
    // all teh codez for HTTP stuffs
}

class Wrapper {
    protected $request;
    protected $commands = [];
    protected $adapters = [];
    public function __construct(Request $request) {
        $this->request = $request;
    }
    public function addCommand(Command $command) { }
    public function getEachCommand() { }
    public function adapt(Rule $rule) {
        $type = get_class($rule);
        return isset($this->adapters[$type]) 
            ? $this->adapters[$type]
            : $this->adapters[$type] = $rule->getAdapter($this);
    }
    public function commit(){
        foreach($this->adapters as $adapter) {
            $adapter->commit($this->request);
        }
    }
}

abstract class Adapter {
    protected $wrapper;
    public function __construct(Wrapper $wrapper) {
        $this->wrapper = $wrapper;
    }
    abstract public function commit(Request $request);
}

因此,给定的用户级别Rule接受了预期的用户级别Adapter。如果Adapter需要有关请求的信息,则通过Wrapper路由,以保留原始Request的完整性。
由于Wrapper聚合Adapter对象,它将现有实例传递给后续的Rule对象,以便从一个Rule到另一个Rule中保留Adapter的状态。一旦整个树形结构已经通过,就会调用Wrapper::commit(),并且每个聚合的Adapter对象将根据需要对原始Request应用其状态。
然后我们得到了一系列Command对象和修改后的Request数组。

到底有什么意义?

嗯,我不想重新创建许多PHP框架/应用程序中常见的典型“路由表”,所以我选择了“路由树”。通过允许任意规则,您可以快速创建并附加一个AuthRule例如)到一个Node,并且没有通过AuthRule就不能访问整个分支。在理论上(在我的头脑中),它像一个神奇的独角兽,防止代码重复,并强制执行区域/模块组织。在实践中,我感到困惑和害怕。

为什么留下这堵无意义的墙?

好吧,这是我需要解决LSP问题的实现。每个Rule对应一个Adapter,这不好。我想保留每个Rule之间的关系,以确保在构建树等时保持类型安全,但是我无法在抽象的Rule中声明关键方法(evaluate()),因为子类型的签名会发生变化。

另外,我正在解决适配器的创建/管理方案;是否由规则负责创建等问题。 “太多信息”结束。

2
我现在没有时间深入探讨,但我认为这是更好地选择组合而非继承的重要原因。你的问题出在你一开始就试图扩展一个抽象类。我的初步建议是使用接口代替抽象类,在方法签名中使用接口类型提示,并通过构造函数根据需要组合功能。 - user895378
@Dan,我发现自己正在问你几年前提出的这个问题。你还记得当时选择了哪条路吗?你的想法从那时起有改变吗? - jules
2个回答

13

为了恰当地回答这个问题,我们需要退一步看待你试图解决的问题,并以更加通用的方式来思考(而且你的问题已经相当泛化了)。

真正的问题

真正的问题是,你试图使用继承来解决业务逻辑问题。由于LSP违规和更重要的是将业务逻辑与应用程序结构紧密耦合,这永远不会起作用。

所以继承不适用于解决这个问题(出于上述原因和你在问题中提到的原因)。幸运的是,有许多组成模式可以使用。

现在,考虑到你的问题非常通用,要确定一个可靠的解决方案将非常困难。因此,让我们回顾一些模式并看看它们如何解决这个问题。

策略模式

当我第一次阅读这个问题时,策略模式是我首先想到的。基本上,它将实现细节与执行细节分开。它允许存在许多不同的“策略”,并且调用者将确定为特定问题加载哪个策略。

这里的缺点是调用者必须了解策略才能选择正确的策略。但它也允许更清晰地区分不同的策略,因此这是一个不错的选择...

命令模式

命令模式 也像策略模式一样解耦实现。主要区别在于,在策略模式中,调用者选择使用哪个消费者。而在命令模式中,是其他人(例如工厂或调度程序)选择使用哪个消费者...

每个“专业消费者”只会为特定类型的问题实现逻辑。然后 其他人 将做出适当的选择。

责任链模式

下一个可能适用的模式是 责任链模式。这与上面讨论的策略模式类似,不同之处在于,每个策略按顺序调用,直到其中一个处理请求。因此,在您的示例中,您将采用更通用的参数,但检查它是否是特定的参数。如果是,则处理请求。否则,让下一个策略尝试处理...

桥接模式

这里也可能适用桥接模式。在某种程度上,它与策略模式相似,但不同之处在于桥接实现将在构建时选择策略,而不是在运行时选择。因此,您需要为每个实现构建不同的“使用者”,其中详细信息作为依赖项组成内部。

访问者模式

您在问题中提到了访问者模式,所以我想在这里提一下。我不确定它是否适用于这种情况,因为访问者与设计用于遍历结构的策略模式非常相似。如果您没有要遍历的数据结构,则访问者模式将被简化为看起来与策略模式非常相似。我说“相当”,因为控制方向不同,但最终关系基本相同。

其他模式

最终,这真的取决于您要解决的具体问题。如果您正在尝试处理HTTP请求,其中每个“消费者”处理不同的请求类型(XML vs HTML vs JSON等),最佳选择可能会与尝试处理多边形的几何区域的情况非常不同。当然,您可以在两者上都使用相同的模式,但它们并不是完全相同的问题。

话虽如此,问题也可以通过中介者模式(在多个“消费者”需要机会处理数据的情况下)、状态模式(在“消费者”依赖于过去消耗的数据的情况下)甚至是适配器模式(在专门的消费者中抽象不同的子系统的情况下)来解决...

简而言之,这是一个难以回答的问题,因为有太多的解决方案,很难说哪个是正确的...


5
我能够帮您进行翻译。以下是需要翻译的内容:

我所知道的唯一方法是DIY策略:在函数定义中接受简单的Argument,并立即检查其是否足够专业:

class SpecializedConsumer extends Consumer {
    public function consume(Argument $argument) {
        if(!($argument instanceof SpecializedArgument)) {
            throw new InvalidArgumentException('Argument was not specialized.');
        }
        // move on
    }
}

1
感谢@dev-null-dweller。我确实考虑过这一点(忘记修改了);这可能是我不得不采取的方向。我已经采用了一种做法:“尽管PHP是动态类型的,并且在函数声明中不需要形式参数类型,但如果一个合理的IDE词法分析器可以跟随我的步伐,那么类型安全性就已经有所实现。”听起来可能很傻,但它可以避免引入模糊性。条件检查作为伪下转换,这也可能需要工作。(我讨厌在谈论PHP时不得不在每个东西前面加上“伪” - Dan Lugg
这违反了LSP原则,同时Consumer::consume方法没有抛出InvalidArgumentException异常。你只是绕开了PHP的检查来实现这个操作。 - Josef Sábl

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