继承和责任

10

每当我阅读有关继承的内容时,总是会对某个示例感到困惑。

通常会有类似下面的示例:

class Shape
{
public:
    Shape() {}
    virtual ~Shape  () {}
    virtual void Draw() = 0;
};

class Cube : public Shape
{
public:
   Cube(){}
   ~Cube(){}
   virtual void Draw();
};

Shape* newCube = new Cube();
newCube->Draw(); 

我的问题是为什么由Shape自己负责绘制?难道不应该由渲染器类知道如何绘制形状并将形状提供给渲染器来处理吗?如果我们想要记录尺寸变化怎么办?等等。我们会在Shape内部为每个不同的任务都编写一个方法吗?

看到这样的例子时,有时让我怀疑我是否能够正确地分配类的职责。我是否没有理解类只有一项职责的含义?

7个回答

4
选择将Draw()作为基类的方法取决于上下文——即要解决的具体问题。为了让问题更加清晰,这里提供另一个例子,我经常用来测试面向对象技能。
想象一下有一个文档类和一个打印机类。那么打印函数应该放在哪里呢?有两个明显的选择:
document.print(Printer &p);

或者
printer.print(Document &d);

哪个是正确的?答案是:这取决于您想在文档还是打印机中实现多态行为。如果我们假设所有打印机具有相同的功能(操作系统试图推广的神话),那么明显多态行为应该在文档对象中。然而,如果我们想象所有文档都大致相同(或者至少是我们关心的文档),而打印机却大不相同(这曾经是事实——考虑:绘图仪、线性打印机、激光打印机、打字轮打印机等),那么让打印机决定如何最好地呈现文档更有意义。
有人可能会认为Print()既不应该属于文档对象,也不应该属于打印机对象,因为多态行为可能需要从打印机和文档的组合中得到。在这种情况下,您需要双重分派

好吧,鉴于我的答案,我想这一点也不意外,但我看不到任何情况下Document应该有一个打印函数。文档只是某个东西而已,它没有任何操作。打印机是一种接受数字文档并以不同形式输出(纸张,PDF)的机器。为什么要改变这个类比呢?Printer接口可以抽象出所有差异(点阵,激光,操作系统版本等)。 - aliteralmind
你做出了所有打印机本质相同的假设。我曾经参与过一个真实项目,其中考虑到以下所有内容都是“打印机”,我可以证明让打印机决定如何呈现文档并不总是正确的答案。例如:绘图仪、PostScript、打字轮打印机、远程幻灯片渲染服务,还有你所提到的所有可寻址点。在这种情况下,让打印机决定如何呈现文档可能比让文档尝试更有意义。 - Dwayne Towell

4
我经常发现这些简单的教科书例子不能充分地解释原因,因为它们过于简单化。我们可以给一个“形状”类很多责任:绘制自己,计算其面积,确定给定点是否在其边界内,确定从另一个形状相交的形状结果,记住有多少人将其视为最喜欢的形状...这个列表只有你的想象力长,你赋予它哪些责任取决于你程序的目标以及你选择如何构建它。

假设你希望以通用的、多态的方式绘制图形,请考虑如何实现。形状将被绘制在什么上面?形状会知道画布如何工作吗?它应该知道它需要拿起画笔,蘸一些颜料然后绘制自己吗?它应该知道显示驱动程序如何工作吗?设置位以打开像素的正确位置,以便您的显示器显示正确的形状?

显然,深入到这个级别给了形状太多的责任,所以你定义一组图形基元(例如:点和线),并构建一个可以渲染这些基元的图形 API。然后,一个“形状”可以使用这些基元告诉 API 绘制什么。图形 API 不知道它正在绘制一个正方形,但通过告诉它绘制四条线,嘿,它就绘制了一个正方形。所有这些都使“形状”只有一个责任,即知道它的点和定义它的线。

当你孤立地学习某些设计模式时,很难看到其好处,因为构建软件是关于让事物一起工作;没有什么东西是孤立存在的。


你的最后一段非常棒。理论讨论只能带给你有限的收获。 - aliteralmind

4
面向对象编程(OOP)鼓励发送信息,而不像过程式代码那样“请求”一些外部数据,然后进行处理。
如果您将“draw”方法放在渲染器中,则会破坏Shape类的封装性,因为它肯定需要访问其内部(如坐标(x,y)等)。
通过让形状自己绘制,您保持了封装性,提高了对内部更改的灵活性。
解决方案实际上取决于复杂程度。通过从Shape中提取draw方法,您的形状将需要公开其数据。通过保持它,您保持了封装性。
因此,如果您的绘图很复杂,请考虑将其视为由Renderer或Graphics承担的另一个完整的责任,这符合您的建议。

@Tek SRP并不严格意味着“一个任务”,而是“一个职责”。一个shape可以做很多不同的事情,只要这些行为确实符合对象的全局职责,即:“描述Shape可以执行的所有任务”。 - Mik378
那么假设我想记录Shape已经改变为不同尺寸的历史记录。我认为这不是Shape的责任来记录和跟踪这些更改...注入一个日志记录器并调用Shape->log(logger)是否正确? - Tek
你的模型类应该避免使用像日志记录器这样的基础设施/框架工具。它应该忽略任何外部依赖关系。在你的情况下,我会实现一个“监听器”,监听形状事件,并相应地处理日志。与此非常相似:http://docs.oracle.com/javase/tutorial/uiswing/events/actionlistener.html - Mik378
看看这个模式,它精确地代表了我刚才解释的概念:http://www.oodesign.com/observer-pattern.html - Mik378
1
如果您加上最后一条评论,会使您的答案更清晰,我会接受这个答案。 - Tek
显示剩余11条评论

3

形状不应该知道它如何被绘制。设计的项目越大,这个决定就越关键。

对我来说,这归结于循环依赖,在除了最边缘的情况下,只会带来麻烦。

模型-视图-控制器的基本原则是,你所做的事情(动词或“视图”)与被操作或分析的东西(名词或“控制器”)明确分离:表现层与逻辑层分离,“模型”扮演中间人角色。

这也是单一职责原则:“每个类都应该有一个单一职责,且该职责应完全封装在该类中。”

原因在于:循环依赖意味着任何更改都会影响所有内容

单一职责原则的另一个摘录(缩短版):“一个类或模块应该有一个且仅有一个变化的原因。单一职责原则认为,问题的实质和表面方面是两个不同的责任,因此应该在不同的类或模块中处理。将因为不同原因在不同时间而发生变化的两件事耦合在一起是一种糟糕的设计。”(重点是我的)

最后,关注点分离的概念:“目标是设计系统,使得函数可以独立于其他函数进行优化,从而一个函数的失败不会导致其他函数失败,并且通常使得理解、设计和管理复杂的相互依赖的系统更加容易。”(重点是我的)


这不仅仅是一个编程设计问题。
考虑网站的开发,"内容"团队必须将他们的文字、格式、颜色和图片非常谨慎地放置在一些脚本周围(由"开发"团队创建),只有这样才能保证一切正常。内容团队希望完全看不到脚本--他们不想学习如何编程,只是为了改变一些单词或微调一张图片。而开发团队也不想担心每一个由不懂编码的人所做出的微小视觉变化都有可能破坏他们的工作。
当我在自己的项目上工作时,我每天都会思考这个概念。当两个源文件相互引用时,任何一个文件的更改都需要同时重新编译两个文件。对于大型项目来说,这可能意味着微小的更改需要重新编译数百或数千个类。在我目前参与的三个主要项目中,大约有一千个不同的源代码文件,其中只有一个这种循环依赖关系。
无论是商业团队、源代码文件还是设计编程对象,循环依赖都是我建议避免的,除非绝对必要。
因此,至少我不会将绘图函数放在Shape中。虽然非常依赖于正在设计的项目的类型和大小,但渲染可以通过一个RenderingUtils类完成,该类仅包含执行大部分工作的公共静态函数。
如果项目规模较大,我会进一步创建Renderable接口作为模型层。 Shape实现Renderable,因此不知道或不关心如何绘制。而进行绘制的任何内容都不需要了解Shape
这使您能够完全更改如何进行渲染,而不影响(或不必重新编译!)Shape,并且还允许您呈现与Shape截然不同的东西,而无需更改绘图代码。

1

只有对象才真正知道如何绘制自己。

想象一下锁匠...他可以打开1000种不同类型的锁。我可以去商店,买任何一把锁交给他,他都能打开,因为他熟悉锁技术。

现在想象一下我是一个发明家,开始制造自己设计独特、革命性的锁。他能够打开它们吗?也许可以,但也许不行...这取决于我在锁内部做了什么,我是否使用他/她所知道的技术等等。

你的形状对象也是如此,取决于它们在内部的实现方式,决定了它们是否可以被某些通用的渲染引擎呈现。如果你要求每个对象自己绘制,则无需担心这个问题。


1
上述答案对我来说似乎过于复杂了。
形状和圆形示例的目的是区分接口(预期如何与外部世界交互)和实现(预期如何运作)之间的差异。
你提供的示例的问题在于它已经被截断了。当涉及到更多形状时,它会更有意义。
考虑拥有一个圆、一个三角形和一个矩形的情况。现在形状要如何绘制自己?它不知道它是哪种形状,也不知道该做什么。
现在考虑一组形状的容器。它们都有一个绘制方法;父类强制执行这一点。因此,即使它们各自的绘图方法的实现本质上是无关的,你仍然可以拥有一个同质的形状容器。
为什么圆形要绘制自己,而不是形状?因为它知道怎么做。

+1 是针对容器示例的,因为它为经典的平凡“形状”示例增加了一些实质内容。 - batwad

0

纯虚函数用于当算法的行为对于一组集合并不明确定义,但算法的存在对于一组集合是明确定义的。

我希望这不会太难理解,但也许从函数分析的课程中可以得到启示。我偏离了虚函数的集合论含义。

让集族A具有属性{x:P(x)}
让A是家族的一个元素
让A'也是家族
的一个元素

A和A'可能属于以下三个类别之一。
(1)A和A'是等价的
对于A的所有a元素,a是A'的元素
并且对于~A的所有b元素,b是~A'的元素

(2)A和A'相交
存在A的元素a,其中a是A'的元素
还存在A的元素b,其中b是~A'的元素

(3) A和A'是不相交的
不存在一个元素a属于A且也属于A'

其中~X指的是所有不属于集合X的x

在情况(1)中,我们将定义一个非抽象行为,当且仅当U是家族A的元素时,存在一个单一值u,使得对于所有作为家族A元素的U,u = P(U)

在情况(2)中,如果U是家族A的元素,则定义虚拟行为意味着存在一个单一值u,使得u = P(U'),其中U'是U的子集。

而在情况(3)中,我们将定义纯虚拟行为,因为A和A'只是相似的成员,它们都是家族A的成员,A和A'的交集为空集,这意味着A和A'没有任何共同的元素

考虑语法在逻辑定义方面的含义,您就能回答以下问题:

(1)这个函数需要是抽象的吗?(对于情况1,不需要;对于情况2和3,需要) (2)这个函数需要是纯虚函数吗?(对于情况1和2,不需要;对于情况3,需要)

在情况2中,还取决于行为所需的信息存储在基类还是派生类中。

你不能从DISPLAY中呈现SHAPE,而不让DISPLAY寻找并非SHAPE定义的必要信息。由于DISPLAY无法看到从SHAPE派生的类型的定义,除了为SHAPE定义的内容之外。因此,任何依赖于包含在派生类型中的信息的功能都必须在派生类中为抽象函数定义。


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