抽象类应包含哪些代码?


10

最近,我对抽象类的使用感到困扰。

有时,会预先创建一个抽象类,并将其作为派生类工作方式的模板。这意味着或多或少地,它们提供了一些高级功能,但是遗漏了某些细节要由派生类实现。抽象类通过放置一些抽象方法来定义对这些细节的需求。在这种情况下,抽象类的工作原理类似于蓝图,功能的高级描述或您想要的任何名称。它不能单独使用,而必须专门用于定义高层实现中遗漏的细节。

在另一些时候,碰巧抽象类是在创建一些“派生”类之后创建的(由于父/抽象类还不存在,所以还没有派生出来,但是您知道我的意思)。在这些情况下,抽象类通常用作放置当前派生类所包含的任何常见代码的地方。

做出上述观察后,我想知道这两个案例中的哪一个应该成为规则。是否应该仅由于它们当前在所有派生类中都是通用的,而将某种细节冒充到抽象类?是否应该存在不属于高级功能的通用代码?

对于抽象类本身可能没有意义的代码是否应该仅仅因为它对派生类是通用的而存在呢?

让我举个例子:抽象类A有一个方法a()和一个抽象方法aq()。在派生类AB和AC中,方法aq()都使用方法b()。b()应该移到A吗?如果是的话,那么如果有人只看着A(假装AB和AC不在那儿),那么b()的存在就没有多大意义了!这是坏事吗?有人应该能够在不访问派生类的情况下浏览抽象类并了解发生了什么吗?

老实说,在提出这个问题时,我倾向于认为编写一个有意义的抽象类而不必查看派生类是一个干净的代码和干净的体系结构的问题。我真的不喜欢抽象类的想法,它像所有代码的转储一样在所有派生类中都很常见。

您如何看待/实践?


7
为什么必须有一个“规则”?
罗伯特·哈维

1
Writing an abstract class that makes sense without having to look in the derived classes is a matter of clean code and clean architecture.-为什么呢?在设计和开发应用程序的过程中,是否有可能发现几个类具有可以自然地重构为抽象类的通用功能?在为派生类编写任何代码之前,我是否必须千方百计以始终能够预见到这一点?如果我不那么聪明,我是否被禁止执行这样的重构?我必须扔掉我的代码并重新开始吗?
罗伯特·哈维

抱歉,如果我误会了!我想说的是(暂时)我认为是一种更好的做法,但并不意味着应该有一个绝对的规则。而且,我并不是在暗示任何属于抽象类的代码都应该事先编写。我在实践中描述了一个抽象类如何最终以高级代码(充当派生代码的模板)和低级代码(如果您不看就无法理解它的可用性)而结束派生的类)。
亚历山德罗斯·古吉斯

@RobertHarvey对于公共基类,将禁止您查看派生类。对于内部类,这没有什么区别。
Frank Hileman '17

Answers:


4

让我举个例子:抽象类A有一个方法a()和一个抽象方法aq()。在派生类AB和AC中,方法aq()都使用方法b()。b()应该移到A吗?如果是的话,那么如果有人只看着A(假装AB和AC在那儿),则b()的存在就没有多大意义了!这是坏事吗?有人应该能够在不访问派生类的情况下浏览抽象类并了解发生了什么吗?

你的询问是哪里的地方b(),而在其他一些意义上的问题是,是否A是最好的选择,因为眼前的超类ABAC

似乎有三个选择:

  1. b()在这两个ABAC
  2. 创建的中间类ABAC-Parent,从继承A和介绍b(),并且随后被用作对直接超类ABAC
  3. b()A(不知道其他班级的未来是否AD会愿意b()与否)

  1. 遭受不干
  2. 患有YAGNI
  3. 所以,剩下这个。

AD不希望b()出现的另一个类出现之前,(3)似乎是正确的选择。

在出现AD礼物的时候,我们可以重构为(2)中的方法-毕竟它是软件!


7
有第四个选择。不要b()上任何课。使它成为一个自由函数,该函数将其所有数据作为参数,并具有两者AB并进行AC调用。这意味着添加时无需移动它或创建任何其他类AD
user1118321

1
@ user1118321,太好了,开箱即用。如果b()不需要实例的长期生存状态,则特别有意义。
Erik Eidt

在(3)中困扰我的是,抽象类不再描述一个独立的行为。它是零碎的代码,如果不在抽象类代码和所有派生类之间来回切换,我将无法理解抽象类代码。
亚历山德罗斯·古格斯

您能否建立一个ac调用的基础/默认值,b而不是ac抽象的?
Erik Eidt

3
对我来说,最重要的问题是,为什么AB和AC都使用相同的方法b()。如果只是巧合,我将在AB和AC中保留两个类似的实现。但这可能是因为AB和AC存在一些通用的抽象。对我来说,DRY本身并不仅仅是一个价值,而是暗示您您可能错过了一些有用的抽象。
Ralf Kleberhoff

2

抽象类并不是为了方便您将各种函数或数据丢入抽象类的转储场所。

似乎提供最可靠和可扩展的面向对象方法的一条经验法则是“优先于继承而不是继承”。最好将抽象类认为是不包含任何代码的接口规范。

如果您有某种方法是与抽象类一起使用的一种库方法,那么这种方法是表达从抽象类派生的类通常需要的某些功能或行为的最可能方法,那么创建一个介于抽象类和可以使用此方法的其他类之间的类。这个新类提供了抽象类的特定实例,通过提供此方法来定义特定的实现替代方案或路径。

抽象类的想法是要有一个抽象模型,说明实际上实现抽象类的派生类应该提供的服务或行为。继承易于使用,因此,最有用的类很多时候都是使用mixin模式由各种类组成的。

但是,总是存在变化的问题,即将发生什么变化,它将如何变化以及为什么会变化。

继承会导致源代码的脆弱和脆弱(另请参见继承:已经停止使用它了!)。

脆弱的基类问题是面向对象的编程系统的基本体系结构问题,其中基类(超类)被认为是“易碎的”,因为当被派生类继承时,对基类的看似安全的修改可能导致派生类发生故障。程序员不能仅通过单独检查基类的方法来确定基类更改是否安全。


我确信,任何OOP概念如果被滥用,过度使用或滥用都会导致问题!此外,假设b()是一种库方法(例如,没有其他依赖项的纯函数)缩小了我的问题范围。让我们避免这种情况。
亚历山德罗斯·古吉斯

@AlexandrosGougousis我不认为这b()是一个纯函数。它可以是仿函数,模板或其他形式。我使用的“库方法”一词是指某些组件,无论是纯函数,COM对象还是用于解决方案的功能使用的任何组件。
理查德·钱伯斯

对不起,如果我不清楚!我在许多示例中都提到了纯函数(您提供了更多示例)。
亚历山德罗斯·古格斯

1

您的问题提出了抽象类的“或”方法。但是我认为您应该将它们视为工具箱中的另一个工具。然后问题就来了:对于抽象类,哪些工作/问题是正确的工具?

一种很好的用例是实现模板方法模式。您将所有不变逻辑放在抽象类中,并将变体逻辑放在其子类中。请注意,共享逻辑本身是不完整的且不起作用。大多数时候,它是关于实现一种算法的,其中几个步骤总是相同的,但是至少一个步骤会有所不同。将这一步作为一种抽象方法,可以从抽象类内部的函数之一中调用它。

有时,会预先创建一个抽象类,并将其作为派生类工作方式的模板。这意味着或多或少地,它们提供了一些高级功能,但是遗漏了某些细节要由派生类实现。抽象类通过放置一些抽象方法来定义对这些细节的需求。在这种情况下,抽象类的工作原理类似于蓝图,功能的高级描述或您想要的任何名称。它不能单独使用,而必须专门用于定义高层实现中遗漏的细节。

我认为您的第一个示例实质上是对模板方法模式的描述(如果我错了,请纠正我),因此我将其视为抽象类的完全有效用例。

在另一些时候,碰巧抽象类是在创建一些“派生”类之后创建的(由于父/抽象类还不存在,所以还没有派生出来,但是您知道我的意思)。在这些情况下,抽象类通常用作放置当前派生类所包含的任何常见代码的地方。

对于您的第二个示例,我认为使用抽象类不是最佳选择,因为存在处理共享逻辑和重复代码的高级方法。假设您有抽象类A,派生类BC并且这两个派生类都以method的形式共享一些逻辑s()。为了确定消除重复的正确方法,重要的是要知道方法是否s()是公共接口的一部分。

如果不是(如您自己的带有method的具体示例b()),则情况非常简单。只需为其创建一个单独的类,然后提取执行该操作所需的上下文。这是构成继承的经典示例。如果几乎不需要上下文,则如某些注释中所建议的那样,简单的帮助程序功能可能已经足够。

如果s()是公共接口的一部分,它将变得有些棘手。根据这一假设s()已经无关B,并C正在相关A,你不应该把s()A。那该放在哪里呢?我主张声明一个单独的接口I,该接口定义了s()。话又说回来,你应该创建一个包含逻辑实现一个单独的类s(),并都BC依赖于它。

最后,是指向SO上一个有趣问题的出色答案的链接,它可以帮助您决定何时使用接口以及何时使用抽象类:

一个好的抽象类将减少必须重写的代码量,因为它的功能或状态可以共享。


我同意s()作为公共接口的一部分会使事情变得更加棘手。我通常会避免定义在同一类中调用其他公共方法的公共方法。
亚历山德罗斯·古格斯

1

在我看来,您无论从逻辑上还是技术上都缺少面向对象的知识。您将描述两种情况:在基类中将类型的常见行为和多态性进行分组。这些都是抽象类的合法应用程序。但是,是否应该使类抽象化取决于您的分析模型,而不取决于技术可能性。

您应该认识到在现实世界中没有化身的类型,但为确实存在的特定类型奠定了基础。例如:一只动物。没有动物。它总是狗,猫或其他任何东西,但没有真正的动物。然而动物将它们全部陷害了。

然后,您谈到水平。在继承方面,级别不属于交易的一部分。也不承认通用数据或通用行为,这是一种可能无济于事的技术方法。您应该识别一个构造型,然后插入基类。如果没有这样的构造型,则最好使用多个由多个类实现的接口。

您的抽象类及其成员的名称应该是有意义的。从技术和逻辑上讲,它必须独立于任何派生类。就像Animal可能有一个抽象方法Eat(将是多态的)和布尔抽象属性IsPet和IsLifestock,这在不了解猫,狗或猪的情况下是有意义的。任何依赖性(技术或逻辑上的依赖性)都只能采用一种方式:从后代到基础。基类本身也不应该知道降级类。


您提到的构造型与我谈论高级功能或其他人谈论由层次结构中较高级别的类描述的广义概念(相对于由较低级别的类描述的更专业的概念)大致相同。等级)。
亚历山德罗斯·古格斯

“基类本身也不应该知道降级类。” 我完全同意这种说法。父类(无论是否抽象)都应包含一条独立的业务逻辑,而无需检查子级实现即可理解。
亚历山德罗斯·古格斯

关于基类了解其子类型还有另一件事。每当开发新的子类型时,都需要修改基类,该基类是向后的并且违反了开闭原则。我最近在现有代码中遇到了这个问题。基类有一个静态方法,该方法根据应用程序的配置创建子类型的实例。目的是一种工厂模式。这样做也会违反SRP。有了一些SOLID点,我曾经认为“嗯,很明显”或“语言将自动解决这一问题”,但是我发现人们比我想象的要更有创造力。
马丁·马特

0

第一种情况,来自您的问题:

在这种情况下,抽象类的工作原理类似于蓝图,功能的高级描述或您想要的任何名称。

第二种情况:

在其他时候……抽象类通常用作放置当前派生类所包含的任何类型通用代码的地方。

然后,您的问题

我想知道这两种情况中的哪一种是规则。...是否应该仅仅因为它对派生类是通用的,就存在对抽象类本身没有意义的代码?

IMO有两种不同的方案,因此您不能绘制一条规则来决定必须采用的设计。

对于第一种情况,我们已经在其他答案中已经提到了诸如“ 模板方法”模式之类的东西。

对于第二种情况,您给出了抽象类A接收ab()方法的示例;IMO b()方法仅在对所有其他派生类有意义时才应移动。换句话说,如果因为在两个地方使用它而将其移动,则可能不是一个好选择,因为明天可能会出现一个新的具体派生类,在该类上b()根本没有任何意义。而且,正如您所说,如果单独查看A类,并且b()在这种情况下也没有意义,那么可能暗示这也不是一个好的设计选择。


-1

前段时间我有完全相同的问题!

我遇到的问题是,每当我偶然发现此问题时,我就会发现我还没有发现一些隐藏的概念。这个概念很可能应该用一些价值对象来表达。您有一个非常抽象的示例,因此恐怕无法证明使用您的代码的含义。但是,这是我个人的做法。我有两个类,它们代表了一种将请求发送到外部资源并解析响应的方法。请求的形成方式和响应的解析方式存在相似之处。这就是我的层次结构:

abstract class AbstractProtocol
{
    /**
     * @return array Registration params to send
     */
    abstract protected function assembleRegistrationPart();

    /**
     * @return array Payment params to send
     */
    abstract protected function assemblePaymentPart();

    protected function doSend(array $data)
    {
        return
            (new HttpClient(
                [
                    'timeout' => 60,
                    'encoding' => 'utf-8',
                    'language' => 'en',
                ]
            ))
                ->send($data);
    }

    protected function log(array $data)
    {
        $header = 'Here is a request to external system!';
        $body = implode(', ', $this->maskData($data));
        Logger::log($header . '. \n ' . $body);
    }
}

class ClassicProtocol extends AbstractProtocol
{
    public function send()
    {
        $registration = $this->assembleRegistrationPart();
        $payment = $this->assemblePaymentPart();
        $specificParams = $this->assembleClassicSpecificPart();

        $dataToSend =
            array_merge(
                $registration, $payment, $specificParams
            );

        $this->log($dataToSend);

        $this->doSend($dataToSend);
    }

    protected function assembleRegistrationPart()
    {
        return ['hello' => 'there'];
    }

    protected function assemblePaymentPart()
    {
        return ['pay' => 'yes'];
    }
}

这样的代码表明我只是滥用继承。这就是重构的方式:

class ClassicProtocol
{
    private $request;
    private $logger;

    public function __construct(Request $request, Logger $logger, Client $client)
    {
        $this->request = $request;
        $this->client = $client;
        $this->logger = $logger;
    }

    public function send()
    {
        $this->logger->log($this->request->getData());
        $this->client->send($this->request->getData());
    }
}

$protocol =
    new ClassicProtocol(
        new PaymentRequest(
            new RegistrationData(),
            new PaymentData(),
            new ClassicSpecificData()
        ),
        new ClassicLogger(),
        new ClassicClient()
    );

class RegistrationData
{
    public function getData()
    {
        return ['hello' => 'there'];
    }
}

class PaymentData
{
    public function getData()
    {
        return ['pay' => 'yes'];
    }
}

class ClassicLogger
{
    public function log(array $data)
    {
        $header = 'Here is a request to external system!';
        $body = implode(', ', $this->maskData($data));
        Logger::log($header . '. \n ' . $body);
    }
}
class ClassicClient
{
    private $properties;

    public function __construct()
    {
        $this->properties =
            [
                'timeout' => 60,
                'encoding' => 'utf-8',
                'language' => 'en',
            ];
    }
}

从那以后,我非常仔细地对待继承,因为我遭受了很多次伤害。

从那时起,我得出了关于继承的另一个结论。我强烈反对基于内部结构的继承。它破坏了封装,很脆弱,毕竟是程序性的。当我以正确的方式分解域时,继承就很少发生。


@Downvoter,请帮我一个忙,并评论这个答案有什么问题。
Vadim Samokhin
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.