在面向对象的语言中,对象应何时对自己进行操作,何时应对对象进行操作?


11

假设有一个Page类,它表示对页面渲染器的一组指令。假设有一个Renderer知道如何在屏幕上呈现页面的类。可以通过两种不同的方式来构造代码:

/*
 * 1) Page Uses Renderer internally,
 * or receives it explicitly
 */
$page->renderMe(); 
$page->renderMe($renderer); 

/*
 * 2) Page is passed to Renderer
 */
$renderer->renderPage($page);

每种方法的优缺点是什么?什么时候会更好?什么时候会更好?


背景

为了增加一些背景知识-我发现自己在同一代码中同时使用了这两种方法。我正在使用名为的第三方PDF库TCPDF。在我的代码中的某些地方,必须具备以下条件才能使PDF呈现工作:

$pdf = new TCPDF();
$html = "some text";
$pdf->writeHTML($html);

假设我希望创建页面的表示形式。我可以创建一个模板,其中包含用于呈现PDF页面摘要的说明,如下所示:

/*
 * A representation of the PDF page snippet:
 * a template directing how to render a specific PDF page snippet
 */
class PageSnippet
{    
    function runTemplate(TCPDF $pdf, array $data = null): void
    {
        $pdf->writeHTML($data['html']);
    }
}

/* To be used like so */
$pdf = new TCPDF();
$data['html'] = "some text";
$snippet = new PageSnippet();
$snippet->runTemplate($pdf, $data);

1)请注意,这里 $snippet 运行本身,如我的第一个代码示例所示。它还需要了解和熟悉$pdf,以及$data它的工作原理。

但是,我可以创建一个PdfRenderer像这样的类:

class PdfRenderer
{
    /**@var TCPDF */
    protected $pdf;

    function __construct(TCPDF $pdf)
    {
        $this->pdf = $pdf;
    }

    function runTemplate(PageSnippet $template, array $data = null): void
    {
        $template->runTemplate($this->pdf, $data);
    }
}

然后我的代码转向这个:

$renderer = new PdfRenderer(new TCPDF());
$renderer->runTemplate(new PageSnippet(), array('html' => 'some text'));

2)在这里,$renderer接收PageSnippet$data工作所需的任何信息。这类似于我的第二个代码示例。

因此,即使渲染器收到页面片段,在渲染器内部,该片段仍会自行运行。也就是说,两种方法都在起作用。我不确定是否可以将OO使用限制为仅一种或另一种。即使您互相掩盖了对方,也可能两者都需要。


2
不幸的是,您已经沿着是否使用空格或制表符,要使用哪种花括号样式的方式进入了软件“宗教战争”的世界。这里没有“更好”的选择,只是双方都有很强的见解。在互联网上搜索富域模型和贫域模型的优缺点,并形成自己的见解。
David Arno

7
@DavidArno使用您的异地空间!:)
candied_orange

1
哈,我有时真的不明白这个网站。能够获得良好答案的完美问题不会因为基于意见而立即被关闭。然而出现了一个明显的基于意见的问题,而这些通常的嫌疑人却无处可寻。哦,好吧,如果您不能击败他们以及所有这些……:)
David Arno

@Erik Eidt,能否请您取消删除答案,因为我认为它是非常好的“第四选择”答案。
David Arno

1
除了SOLID原理外,您还可以查看GRASP,尤其是在Expert方面。问题是,哪些信息可以帮助您履行职责?
OnesimusUnbound '18

Answers:


13

这完全取决于您认为OO是什么

对于OOP = SOLID,如果该操作属于该类的“单一职责”,则应属于该类。

对于OO =虚拟分派/多态性,如果应该动态分派操作(即如果通过接口调用它),则该操作应该是对象的一部分。

对于OO =封装,如果该操作使用了您不想公开的内部状态,则该操作应该是该类的一部分。

对于OO =“我喜欢流畅的界面”,问题是哪种变体更自然地读取。

对于OO =对真实世界的实体建模,哪个真实世界的实体执行此操作?


所有这些观点通常都是孤立的错误。但是有时候,这些观点中的一个或多个有助于做出设计决策。

例如,使用多态性观点:如果您有不同的渲染策略(例如不同的输出格式或不同的渲染引擎),则$renderer->render($page)很有意义。但是,如果您有不同的页面类型,应该以不同的方式呈现,那$page->render()可能会更好。如果输出取决于页面类型和呈现策略,则可以通过访问者模式进行两次分派。

不要忘记,在许多语言中,函数不必一定是方法。一个简单的函数,例如render($page),通常是一个非常好的(非常简单)的解决方案。


等等。如果页面包含对渲染器的引用,但不知道它拥有哪个渲染器,我仍然可以获得多态渲染。这只是意味着多态性在兔子洞的下方。我还可以选择要传递给渲染器的内容。我不必通过整个页面。
candied_orange

@CandiedOrange很好,但是我会在SRP下预定您的论点:决定页面的呈现方式是Page的大写R责任,也许使用某种多态呈现策略。
阿蒙(Amon)

我认为$renderer将会决定如何渲染。当与所有人$page交谈时,$renderer它说的是渲染内容。不行 在$page不知道如何。那让我陷入SRP麻烦了吗?
candied_orange

我真的不认为我们不同意。我试图将您的第一条评论归类到该答案的概念框架中,但我可能使用了笨拙的词。您要提醒我的一件事是,我没有在答案中提及:告诉不询问数据流也是一种很好的启发式方法。
阿蒙(Amon)

嗯。。好。你是对的。我一直在谈论的是要不要问。如果我错了,现在纠正我。另一种策略是渲染器获取页面引用,这意味着渲染器将不得不使用页面获取器来转身向页面询问内容。
candied_orange

2

根据艾伦·凯Alan Kay)的说法,物体是自给自足,“成年”且负责任的生物。成人做事,他们不做手术。也就是说,金融交易负责保存自身,页面负责渲染自身,等等,等等。更简洁地说,封装是OOP中的大事。特别是,它通过著名的“不要问了”原则(@CandiedOrange一直都在提:)来体现,并且公开获取和使用getter和setter方法

实际上,它导致对象拥有完成工作所需的所有必要资源,例如数据库工具,渲染工具等。

因此,考虑您的示例,我的OOP版本将如下所示:

class Page
{
    private $data;
    private $renderer;

    public function __construct(ICanRender $renderer, $data)
    {
        $this->renderer = $renderer;
        $this->data = $data;
    }

    public function render()
    {
        $this->renderer->render($this->data);
    }
}

如果您有兴趣,David West在他的《对象思维》一书中谈到了原始的OOP原则。


1
坦率地说,十五年前,除了出于历史利益,谁还会在乎别人对软件开发的看法?
David Arno

1
我很在乎一个发明了面向对象概念的人所说的是什么对象。 ”为什么?除了诱使您在论点中使用“诉诸权威”的谬论之外,一个术语的发明者的想法对15年后该术语的应用可能产生什么影响?
David Arno

2
@Zapadlo:您没有提出一个论点,为什么消息是从Page发送到Renderer,而不是相反。他们都是对象,因此都是成年人,对吗?
JacquesB

1
不能在这里应用对权威谬论的呼吁 ”……“ 所以您认为代表OOP的概念集实际上是错误的(因为它是对原始定义的歪曲) ”。我认为您不知道对权威谬论的吸引力是什么?线索:您在这里用过一个。:)
David Arno

1
@David Arno所以,所有对权威的呼吁都是错误的吗?您是否愿意“对我的意见具有吸引力?” 每当有人引用伯伯主义叔叔时,您会抱怨诉诸权威吗?Zapadio提供了一个受人尊敬的消息来源,您可以不同意或援引冲突的消息来源,但一再地抱怨有人提供的引用并不具有建设性
。– user949300

2

$page->renderMe();

在这里,我们page完全负责渲染本身。它可能已经通过构造函数提供了渲染,也可能内置了该功能。

在这里,我将忽略第一种情况(通过构造函数提供渲染),因为这与将其作为参数传递非常相似。相反,我将研究内置功能的优缺点。

优点是它允许很高水平的封装。该页面无需直接透露其内部状态。它仅通过自身的渲染来公开它。

缺点是它违反了单一责任原则(SRP)。我们有一个类负责封装页面的状态,并且还使用关于如何呈现自身的规则进行硬编码,因此可能还要承担其他一系列责任,因为对象应该“对自己做事,而不是由别人来做事” ”。

$page->renderMe($renderer);

在这里,我们仍然需要页面能够呈现自身,但是我们为页面提供了可以进行实际呈现的帮助对象。这里可能出现两种情况:

  1. 该页面只需要知道渲染规则(以哪种顺序调用哪种方法)即可创建该渲染。保留了封装,但是由于页面仍必须监督呈现过程,因此SRP仍然被破坏,或者
  2. 该页面仅在渲染器对象上调用一种方法,传递其详细信息。我们越来越接近于尊重SRP,但是现在我们削弱了封装。

$renderer->renderPage($page);

在这里,我们充分尊重了SRP。页面对象负责在页面上保存信息,渲染器负责渲染该页面。但是,我们现在已经完全削弱了页面对象的封装,因为它需要使整个状态公开。

此外,我们还创建了一个新问题:渲染器现在与页面类紧密耦合。当我们想呈现与页面不同的东西时会发生什么?

哪个最好?没有一个 他们都有缺点。


反对V3尊重SRP。Renderer至少有2个更改的原因:Page更改了,或渲染方式更改了。第三,如果Renderer需要呈现Pages以外的对象,则需要介绍。否则,很好的分析。
user949300 '18

2

这个问题的答案是明确的。这是$renderer->renderPage($page);正确的实现。要了解我们如何得出此结论,我们需要了解封装。

什么是页面?它是某人将消费的显示的表示。该“某人”可以是人类,也可以是机器人。请注意,Page是表示形式,而不是显示本身。代表是否存在而没有代表?页面是否没有渲染器?答案是肯定的,表示可以不存在而存在。代表是一个后期阶段。

什么是没有页面的渲染器?渲染器可以渲染而不显示页面吗?否。因此,Renderer接口确实需要该renderPage($page);方法。

这有什么错$page->renderMe($renderer);

事实renderMe($renderer)仍然是必须在内部调用$renderer->renderPage($page);。这违反得墨忒耳定律其中规定

每个单元应仅对其他单元具有有限的知识

Page类不关心是否存在Renderer在宇宙中。它只关心页面的表示。因此,Renderer绝对不应在内提及类或接口Page


更新的答案

如果我的问题正确无误,则PageSnippet该类仅应与页面摘要有关。

class PageSnippet
{    
    /** string */
    private $html;

    function __construct($data = ['html' => '']): void
    {
        $this->html = $data['html'];
    }

   public function getHtml()
   {
       return $this->html;
   }
}

PdfRenderer 与渲染有关。

class PdfRenderer
{
    /**@var TCPDF */
    protected $pdf;

    function __construct(TCPDF $pdf = new TCPDF())
    {
        $this->pdf = $pdf;
    }

    function runTemplate(string $html): void
    {
        $this->pdf->writeHTML($html);
    }
}

客户使用

$renderer = new PdfRenderer();
$snippet = new PageSnippet(['html' => '<html />']);
$renderer->runTemplate($snippet->getHtml());

需要考虑的几点:

  • $data作为关联数组传递的坏习惯。它应该是一个类的实例。
  • 页面格式包含在数组html属性内的事实$data是特定于您的域的详细信息,并且PageSnippet知道此详细信息。

但是,如果除了Pages,您还有图片,文章和Triptichs,该怎么办?在您的方案中,渲染器必须了解所有这些信息。大量泄漏。值得深思。
user949300

@ user949300:好吧,如果渲染器需要能够渲染图片等,那么显然需要了解它们。
JacquesB

1
肯特·贝克(Kent Beck)的Smalltalk最佳实践模式介绍了“ 反转方法”模式,这两种模式均受支持。链接的文章显示对象支持一种printOn:aStream方法,但是它所做的只是告诉流打印该对象。与您的答案的比喻是,没有理由您无法同时拥有可以渲染到渲染器的页面和可以渲染页面的渲染器,同时具有一种实现和方便的界面选择。
格雷厄姆·李

2
在任何情况下,您都将不得不破坏/捏造SRP,但是如果Renderer需要知道如何渲染许多不同的事物,那确实是“许多责任”,并且在可能的情况下要避免。
user949300 '18

1
我喜欢您的回答,但我很容易想到,Page不知道$ renderer是不可能的。我在问题中添加了一些代码,请参见PageSnippet类。它实际上是一个页面,但是如果不对进行某种引用就不能存在$pdf,实际上在这种情况下,它是第三方PDF渲染器。..但是,我想尽管可以创建一个PageSnippet仅包含PDF文本指令数组的类,并让其他类解释这些指令。这样,我能避免注射$pdfPageSnippet,在额外的复杂性为代价
丹尼斯

1

理想情况下,您希望类之间的依赖关系尽可能少,因为这样可以降低复杂性。一个类仅在确实需要时才依赖于另一个类。

您声明Page包含“一组页面渲染器指令”。我想象这样的事情:

renderer.renderLine(x, y, w, h, Color.Black)
renderer.renderText(a, b, Font.Helvetica, Color.Black, "bla bla...")
etc...

就是这样$page->renderMe($renderer),因为Page 需要引用渲染器。

但是替代地,渲染指令也可以表示为数据结构而不是例如直接调用。

[
  Line(x, y, w, h, Color.Black), 
  Text(a, b, Font.Helvetica, Color.Black, "bla bla...")
]

在这种情况下,实际的渲染器将从Page获取此数据结构,并通过执行相应的渲染指令对其进行处理。通过这种方法,依赖关系将被逆转-页面不需要了解渲染器,但是应该为渲染器提供一个页面,然后可以对其进行渲染。所以选择二:$renderer->renderPage($page);

那么哪个最好呢?第一种方法可能最容易实现,而第二种方法则更加灵活和强大,因此我想这取决于您的要求。

如果您不能决定,或者您认为将来可能会改变方法,则可以将决定隐藏在间接层(函数)的后面:

renderPage($page, $renderer)

我不会推荐的唯一方法是,$page->renderMe()因为它建议页面只能有一个渲染器。但是,如果您有一个ScreenRenderer并添加一个,该PrintRenderer怎么办?两者可能呈现相同的页面。


在EPUB或HTML的上下文中,没有渲染器就不存在页面的概念。
mouviciel

1
@mouviciel:我不确定我明白你的意思。您肯定可以拥有一个HTML页面而不渲染它吗?例如,Google抓取工具处理页面而不呈现它们。
JacquesB

2
页面一词有一个不同的概念:HTML页面格式化为要打印时的分页过程的结果,也许就是@mouviciel想到的内容。但是,在这个问题上,a page显然是对渲染器的输入,而不是输出,因为该概念显然不合适。
布朗

1

SOLIDD部分

“抽象不应该依赖细节。细节应该依赖抽象。”

那么,在Page和Renderer之间,哪个更可能是稳定的抽象,更不可能更改,可能表示一个接口?相反,哪个是“细节”?

以我的经验,抽象通常是渲染器。例如,它可能是一个简单的Stream或XML,非常抽象且稳定。或一些相当标准的布局。您的页面更有可能是自定义业务对象,即“详细信息”。而且您还有其他要渲染的业务对象,例如“图片”,“报告”,“图表”等...(可能不是我评论中的“ tryptich”)

但这显然取决于您的设计。页面可以是抽象的,例如,等同于<article>带有标准子部分的HTML 标记。而且您有许多不同的自定义业务报告“渲染器”。在这种情况下,渲染器应依赖页面。


0

我认为大多数课程可以分为以下两个类别之一:

  • 包含数据的类(可变或不可变无关紧要)

这些类几乎不依赖于其他任何类。它们通常是您域的一部分。它们应该不包含任何逻辑或仅包含可以直接从其状态导出的逻辑。Employee类可以具有isAdult可以直接从其类派生birthDate的功能hasBirthDay,但不能具有需要外部信息(当前日期)的功能。

  • 提供服务的课程

这些类型的类在包含数据的其他类上运行。它们通常配置一次且不可变(因此它们始终执行相同的功能)。但是,此类仍然可以提供有状态的短期帮助程序实例,以执行需要在短期内保持某种状态的更复杂的操作(例如Builder类)。

你的例子

在您的示例中,Page将是一个包含数据的类。如果该类是可变的,它应该具有获取此数据并可能对其进行修改的函数。保持哑巴,这样就可以在没有很多依赖的情况下使用它。

数据,或者在这种情况下,您Page可以通过多种方式表示您的数据。它可以呈现为网页,写入磁盘,存储在数据库中,转换为JSON等。您不想为每种情况都向此类添加方法(即使您的类只包含数据,也要创建对所有其他类的依赖)。

Renderer是典型的服务类型类。它可以对一组特定的数据进行运算并返回结果。它没有太多的状态,通常具有不可变的状态,可以配置一次,然后重新使用。

例如,您可以具有类的MobileRendererStandardRenderer两个实现,Renderer但设置不同。

因此,由于Page包含数据并应保持哑巴,这种情况下最干净的解决方案是将传递Page给a Renderer

$renderer->renderPage($page)

2
非常程序化的逻辑。
user949300 '18
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.