Dependency Inversion and interfaces

7

我正在学习面向对象编程,特别是接口。我也试图学习SOLID原则,具体来说是D原则。

这个网站上可以看到,初始程序实现了一个“具体实现”,在这种情况下,PDFBook被类型提示为要传递给构造函数的参数。稍后,将此类型提示更改为通用的EBook接口。任何实现此接口的内容都将被接受。在这种情况下是有意义的。

然而,即使编码到一个接口,我发现通常会有额外的方法没有在接口中定义,但是对于该具体实现是独有的。在这种情况下,PDFBook可能有一个doDPFOnlyThing方法,在实现EBook接口的其他类中没有定义。

如果我将一个PDFBook对象传递给myFunc(),并且类型提示为EBook接口,则据我所知,如果我仅使用接口中定义的方法- read() - 那么这将遵循DIP原则,是吗?也就是说,传递到myFunc()中实现接口的任何内容都将能够调用其read()方法,因为它遵循接口契约。

myFunc(Ebook $book) {

    $book->read();
}

如果我的函数 myFunc() 必须使用仅在 PDFBook 类中可用的 doDPFOnlyThing() 方法,那该怎么办?我认为这将增加依赖关系,因为此方法仅存在于 PDFBook 具体类中。
myFunc(Ebook $book) {

    $book->doDPFOnlyThing();
}

在这种情况下,应该采取哪种更好的行动呢?
3个回答

7

虽然在接口上使用类型提示而不是实现有助于减少耦合,但在尝试编写通用接口时可能会变得很麻烦。就像你说的那样,使用已知方法会更好。

话虽如此,事实上你有两种不同的方法。当调用myFunc并传递一个EBook时,你应该只依赖于接口中的方法。如果一个方法需要调用doPDFOnlyThing并且它依赖于一个EBook而不是PDFBook,那么这将违反这个原则。

你可以做的一件事是:

public myFunc(EBook $book)
{
    $book->read();
}

public myPDFFunc(PDFBook $book)
{
    $book->read(); //Still valid from EBook contract
    $book->doPDFOnlyThing();
}

尽管这可能有效,但它是一种肮脏的解决方法,您很可能会在此过程中违反开放/封闭原则,因为您将回来并编辑该类。(最终客户将需要一个具有doKindleOnlyThing方法的KindleBook。)
那么如何解决这个问题呢?
你想要对一个接口进行类型提示,但又想使用实现中的方法,就像你想要拥有蛋糕却又想吃掉它一样...
为了解决这个问题,您需要更抽象地设计您的代码。让我们以以下代码为例,您正在制作一个客户端,用于阅读各种格式的书籍,这些书籍都派生自EBook接口,并实现为基类MyEBook
interface EBook
{
    public function read();
}

interface PDFBook extends EBook
{
    public function doPDFOnlyThing();
}

class MyEBook implements EBook
{
    public function read()
    {
        echo 'reading from a ' . get_class($this);
    }
}

class MyPDFBook extends MyEBook implements PDFBook
{
    public function read()
    {
        //you only need to override this method
        //if needed, otherwise you can leave it
        //out and default to the parent class
        //implementation.
        parent::read();
    }

    public function doPDFOnlyThing()
    {
        echo 'doing what a PDF does while...';
    }
}

EBook接口约定了read()方法,PDFBook接口扩展了EBook并添加了doPDFOnlyThing()方法到接口中。具体实现MyEBookMyPDFBook将各自使用它们的接口。

接下来,我们需要构建一些处理器类,可以对任何书籍执行某种操作。我们将在这里使用一种命名规则,即所有处理器类的后缀都为Reader。因此,MyPDFBook的处理器将是MyPDFBookReader。稍后这个约定将会很方便。

我们将从一个抽象类开始,该类可以接受任何EBook的实现,并将其存储在类属性中。该类还期望所有子类实现一个名为readBook()的方法。

abstract class GenericBookReader
{
    protected $book;

    public function __construct(EBook $book)
    {
        $this->book = $book;
    }

    abstract public function readBook();
}

现在我们有了可以接受任何 EBook 的抽象类,我们可以构建特定的实现,这些实现将对特定的接口类进行类型提示 - 例如 PDFBookEBook
class MyBookReader extends GenericBookReader
{
    public function __construct(EBook $book)
    {
        parent::__construct($book);
    }

    public function readBook()
    {
        $this->book->read();
    }
}

class MyPDFBookReader extends GenericBookReader
{
    public function __construct(PDFBook $book)
    {
        parent::__construct($book);
    }

    public function readBook()
    {
        //You are safe to use PDFBook methods here
        //because you have a guarantee they are available
        $this->book->doPDFOnlyThing();
        $this->book->read();
    }
}

这两个具体实现只是将给定的对象$book发送到父构造函数中,然后在 $this->book 属性中缓存它。任何需要在初始化时对书籍进行的操作都可以在GenericBookReader中完成,所有类都将使用新方法,而不必逐个进行更新。当然,如果特定的类需要一些特殊的初始化,则可以在它们自己的构造函数中完成,而不是在父构造函数中。
目前为止,您已经将EBookPDFBook分别放置在自己的处理程序中,而不是在单个类中。这是一步向前的进展,因为现在在MyPDFBookReader类的readBook()方法中,您有一个保证可以使用doPDFOnlyThing()
现在,为了将所有这些组合在一起,您需要一个读书客户端。客户端应该能够接受任何EBook,确定它是什么类型的书,创建适当的Reader类,然后调用readBook()方法。这里的命名约定很好用,因为我们可以动态地构建类名称。
class BookClient
{
    public function readBook(EBook $book)
    {
        //Get the class name of $book
        $name = get_class($book);

        //Make the 'reader' class name and see if it exists
        $readerClass = $name . 'Reader';
        if (class_exists($readerClass))
        {
            //Class exists - yay!  Read the book...
            $reader = new $readerClass($book);
            $reader->readBook();
        }
    }
}

这些类的用法如下:
$client = new BookClient();
$client->readBook(new MyEBook());       //prints: reading from a MyBook
$client->readBook(new MyPDFBook());     //prints: doing what a PDF does while...reading from a MyPDFBook

所有这些看起来可能很复杂,只是为了进行一个简单的readBook()调用,但所获得的灵活性是值得的。例如,稍后客户会说"哪里支持Kindle图书?",而你会说"马上就来!"
interface KindleBook extends EBook
{
    public function doKindleOnlyThing();
}

class MyKindleBook extends MyEBook implements KindleBook
{
    public function doKindleOnlyThing()
    {
        echo 'waiting FOREVER for the stupid menu to start...';
    }
}

class MyKindleBookReader extends GenericBookReader
{
    public function __construct(KindleBook $book)
    {
        parent::__construct($book);
    }

    public function readBook()
    {
        //You are safe to use KindleBook methods here
        //because you have a guarantee they are available
        $this->book->doKindleOnlyThing();
        $this->book->read();
    }
}

扩展示例用法:

$client = new BookClient();
$client->readBook(new MyEBook());       //prints: reading from a MyBook
$client->readBook(new MyPDFBook());     //prints: doing what a PDF does while...reading from a MyPDFBook
$client->readBook(new MyKindleBook());  //prints: waiting FOREVER for the stupid menu to start...reading from a MyKindleBook

这种使用抽象化的设置很好地支持了开闭原则。你需要添加一些代码,但是你没有更改任何现有的实现-甚至不包括客户端!

希望这提供了一个额外的角度来看待您的问题。看看您想要设置实现的方式,并开始查看可以抽象出来的内容。有时最好让对象彼此独立,并具有与它们一起工作的特殊处理程序。在这个例子中,没有任何一本书需要关心其他书是如何工作的。因此,一个类接受任何EBook,但具有与该接口的特定子实现一起工作的方法最终成为代码异味。

希望这可以帮助您。以下是完整的示例代码,可复制并粘贴以自行尝试。

<?php

interface EBook
{
    public function read();
}

interface PDFBook extends EBook
{
    public function doPDFOnlyThing();
}

interface KindleBook extends EBook
{
    public function doKindleOnlyThing();
}

class MyEBook implements EBook
{
    public function read()
    {
        echo 'reading from a ' . get_class($this);
    }
}

class MyPDFBook extends MyEBook implements PDFBook
{
    public function read()
    {
        //you only need to override this method
        //if needed, otherwise you can leave it
        //out and default to the parent class
        //implementation.
        parent::read();
    }

    public function doPDFOnlyThing()
    {
        echo 'doing what a PDF does while...';
    }
}

class MyKindleBook extends MyEBook implements KindleBook
{
    public function doKindleOnlyThing()
    {
        echo 'waiting FOREVER for the stupid menu to start...';
    }
}

abstract class GenericBookReader
{
    protected $book;

    public function __construct(EBook $book)
    {
        $this->book = $book;
    }

    abstract public function readBook();
}

class MyBookReader extends GenericBookReader
{
    public function __construct(EBook $book)
    {
        parent::__construct($book);
    }

    public function readBook()
    {
        $this->book->read();
    }
}

class MyPDFBookReader extends GenericBookReader
{
    public function __construct(PDFBook $book)
    {
        parent::__construct($book);
    }

    public function readBook()
    {
        //You are safe to use PDFBook methods here
        //because you have a guarantee they are available
        $this->book->doPDFOnlyThing();
        $this->book->read();
    }
}

class MyKindleBookReader extends GenericBookReader
{
    public function __construct(KindleBook $book)
    {
        parent::__construct($book);
    }

    public function readBook()
    {
        //You are safe to use KindleBook methods here
        //because you have a guarantee they are available
        $this->book->doKindleOnlyThing();
        $this->book->read();
    }
}

class BookClient
{
    public function readBook(EBook $book)
    {
        //Get the class name of $book
        $name = get_class($book);

        //Make the 'reader' class name and see if it exists
        $readerClass = $name . 'Reader';
        if (class_exists($readerClass))
        {
            //Class exists - yay!  Read the book...
            $reader = new $readerClass($book);
            $reader->readBook();
        }
    }
}
$client = new BookClient();
$client->readBook(new MyEBook());       //prints: reading from a MyBook
$client->readBook(new MyPDFBook());     //prints: doing what a PDF does while...reading from a MyPDFBook
$client->readBook(new MyKindleBook());  //prints: waiting FOREVER for the stupid menu to start...reading from a MyKindleBook

这是一个非常棒的答案,值得明天奖励。你提供了比我购买的一些零售书籍更好的详细解释。 - myol
@myol 如果你觉得有价值的话,我之前遇到了一个问题这里,我写了一个(冗长的)答案,其中也涉及了一些面向对象编程原则、设计、抽象等方面。 - Crackertastic
谢谢,那个链接也非常有帮助。不过我有一个问题,为什么你要使用抽象类来定义GenericBookReader?这样做有什么好处呢? - myol
1
@myol 我使用抽象类是因为我可以提供一些基本功能,同时也可以约束子类必须实现的方法 - 这是接口无法做到的。如果我不这样做,我将不得不在每个Reader类中创建一个$book属性,并在每个类的构造函数中进行赋值。此外,也不可能有默认行为。如果您需要设置书籍作者的名称,则setAuthor($author)方法适合放在父类中,而不是在所有类中重复自己。虽然在示例中只有3个类,但如果有20个呢? - Crackertastic
我查阅了一些关于抽象类的材料,现在我认为我理解了。它就像是一个部分构建的类,可以继承其常规方法,但具有接口契约功能?介于普通类和纯接口之间的东西? - myol
没错,这是一个很好的看待它们的方式。抽象类也不能被实例化,因此必须像接口一样使用具体类来实现。抽象类还可以用作实现接口并提供默认行为的方式。 - Crackertastic

4
这个问题一直在热议,但有一个选项是创建一个符合你实现对象完整定义的接口。

然而,这违反了开闭原则,因为你的实现中可能并不需要所有方法作为它所依赖的内容。详情请参阅此SO帖子

另一个选项是为你的类中精确的依赖创建接口,然后选择仅实现这些依赖的实现。有时候你可能需要创建一个新的实现,进行一个现有实现的重构,或者创建一个包装器来包含现有实现。


谢谢,我决定为确切的依赖项创建一个接口。 - myol

2
在这种情况下,它也违反了SOLID原则中的"L",因为引用有可能会抛出NOSuchMethodFoundException,就像在Java中一样。
在您的情况下,您需要两个接口,一个仅具有read()函数,另一个具有doPDFOnlyThing()函数,因此现在您已经创建了一个适配器,可以调用较低级别的实现,以后您还可以使用包含doPDFOnlyThing()的接口来处理其他实现,如图像PDF、安全PDF等。因此,您需要实现两个接口。

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