OOP中的零行为对象-我的设计难题


94

OOP背后的基本思想是数据和行为(基于数据)是不可分割的,并且它们与类对象的思想联系在一起。对象具有与之配合使用的数据和方法(以及其他数据)。显然,根据OOP的原理,仅仅是数据的对象(如C结构)被视为反模式。

到现在为止还挺好。

问题是我注意到我的代码最近朝着这种反模式的方向发展。在我看来,我越努力实现隐藏在类与松散耦合设计之间的信息,我的类就越成为纯数据无行为类和所有行为无数据类的混合。

我通常以一种使类对其他类的存在的意识最小化并且对其他类的接口的知识最小化的方式设计类。我特别以自上而下的方式执行此操作,较低级别的类不了解较高级别的类。例如:

假设您有一个通用的纸牌游戏API。你上课了Card。现在,该Card课程需要确定玩家的可见度。

一种方法是有boolean isVisible(Player p)Card类。

另一种是有boolean isVisible(Card c)Player类。

我特别不喜欢第一种方法,因为它将有关较高级别的知识授予Player较低级别的知识Card

取而代之的是,我选择了第三个选项,在该选项中,我们有一个Viewport类,给定一个,Player并且卡片列表确定了哪些卡片是可见的。

然而这种做法剥夺了两个CardPlayer一个可能的成员函数的类。一旦完成了除卡片可见性之外的其他工作,就剩下了CardPlayer类,它们仅包含数据,因为所有功能都是在其他类中实现的,这些类大多是没有数据的类,只是Viewport上面的方法。

这显然与OOP的主要思想背道而驰。

哪种方法正确?我应该如何进行最小化类相互依赖性和最小化假定的知识和耦合的任务,而又不会陷入怪异的设计中,其中所有低层类仅包含数据,而高层类包含所有方法?是否有人对类设计有任何第三种解决方案或观点可以避免整个问题?

PS这是另一个例子:

假设您的类DocumentId是不可变的,只有一个BigDecimal id成员和该成员的吸气剂。现在,您需要在某处有一个方法,该方法会从数据库中获得该ID 的DocumentId返回值Document

你呢:

  • Document getDocument(SqlSession)DocumentId类中添加方法,突然介绍有关您的persistence("we're using a database and this query is used to retrieve document by id"),用于访问数据库的API等知识。现在,此类也需要持久性JAR文件才能进行编译。
  • 使用method添加其他类Document getDocument(DocumentId id),将DocumentId类保持为无效,无行为,类似于结构的类。

21
您在这里的一些前提是完全错误的,这将使回答基本问题变得非常困难。使您的问题尽可能简洁明了,您将获得更好的答案。
pdr

31
“这显然违背了面向对象编程的主要思想”-不,不是,而是普遍的谬论。
Doc Brown

5
我想问题在于,过去有过很多关于“对象定向”的流派-它最初是由像Alan Kay这样的人表示的(请参阅geekswithblogs.net/theArchitectsNapkin/archive/2013/09/08/ …),Rational的那些人在OOA / OOD的上下文中教授了这种方法(en.wikipedia.org/wiki/Object-directional_analysis_and_design)。
Doc Brown

21
我想说,这是一个很好的问题,并且贴得很好-与其他一些评论相反。它清楚地表明了关于如何构造程序的大多数建议是多么幼稚或不完整-以及执行它有多么困难,并且在许多情况下,无论人们有多少努力去做正确的设计,正确的设计是多么难以实现。尽管对特定问题的一个明显答案是多方法,但是设计的基本问题仍然存在。
Thiago Silva

5
谁说没有行为的课程是反模式?
James Anderson

Answers:


42

您所描述的被称为贫血域模型。与许多OOP设计原则(如Demeter定律等)一样,仅仅为了满足一条规则,也不值得向后弯腰。

只要拥有价值不菲的包,只要它们不会使整个景观变得混乱并且不依赖其他对象来做自己可以做的家务活,那没错。

如果您有一个单独的类仅用于修改-的属性,那肯定是代码的味道Card-如果可以合理地期望它自己照顾它们。

但是Card,知道Player它对谁可见真的是一项工作吗?

而为什么要实施Card.isVisibleTo(Player p),却不能Player.isVisibleTo(Card c)呢?或相反亦然?

是的,您可以像以前一样尝试为此制定某种规则-例如PlayerCard(?)更高级别-但这并不是那么容易猜到的,我将不得不寻找多个地方找到方法。

随着时间的流逝,这可能导致isVisibleTo在类和类上 实现的糟糕的设计折衷,我认为这是不行的。为什么这样?因为我已经想象到可耻的一天将返回与我认为不同的值-这是主观的-这应该通过设计使其无法实现。CardPlayerplayer1.isVisibleTo(card1)card1.isVisibleTo(player1).

卡和球员共同的知名度应该更好地通过某种上下文对象的管辖-是它ViewportDealGame

这不等于具有全局功能。毕竟,可能会有很多并发游戏。请注意,同一张卡可以同时在许多桌子上使用。我们是否会Card为每个黑桃王牌创建许多实例?

我可能仍然isVisibleTo在上实现Card,但是将上下文对象传递给它,并Card委托查询。编程接口以避免高耦合。

至于第二个示例-如果文档ID仅包含一个BigDecimal,为什么还要为其创建包装器类?

我只想说一个 DocumentRepository.getDocument(BigDecimal documentID);

顺便说一句,尽管Java中缺少,但structC#中有s。

看到

以供参考。这是一种高度面向对象的语言,但是没人能从中获得很多收益。


1
只是关于C#中的结构的注释:它们不是典型的结构,就像您在C中所知道的那样。实际上,它们还通过继承,封装和多态性来支持OOP。除了某些特殊性外,主要区别在于运行时将实例传递给其他对象时如何处理它们:结构是值类型,类是引用类型!
2014年

3
@Aschratt:结构不支持继承。结构可以实现接口,但是实现接口的结构的行为与类对象的行为不同。尽管可以使结构的行为类似于对象,但结构的最佳用例是当人们想要某种行为类似于C结构,而被封装的对象是基元或不可变的类类型时。
supercat 2014年

1
+1为何说明“这不等于具有全局功能”。其他人并没有解决这个问题。(尽管如果您有多个卡片组,则全局功能对于同一张卡的不同实例仍将返回不同的值)。
Alexis 2014年

@supercat这值得一个单独的问题或一个聊天会话,但我目前对以下两个都不感兴趣:-(您说(在C#中)“实现接口的结构与执行同样操作的类对象不同”。我同意还有其他行为差异的考虑,但据我所知在代码之后Interface iObj = (Interface)obj;,行为iObj不受到structclass状态obj(除了这将是一个盒装拷贝在该分配,如果它是一个struct)。
马克·赫德

150

OOP背后的基本思想是数据和行为(基于数据)是不可分割的,并且它们与类对象的思想联系在一起。

假设类是OOP 中的基本概念,这是一个普遍的错误。类只是实现封装的一种特别流行的方式。但是我们可以让它滑下来。

假设您有一个通用的纸牌游戏API。您有班级卡。现在,此类卡需要确定玩家的可见性。

好货号 当您在玩Bridge时,您是否何时将假人的手从只有假人知道的秘密变成所有人都知道的七颗心?当然不是。这根本不是卡的问题。

一种方法是在Card类上具有boolean isVisible(Player p)。另一个是在Player类上具有boolean isVisible(Card c)。

两者都很恐怖。不要做任何一个。无论是球员还是负责实施大桥的规则!

相反,我选择了第三个选项,其中有一个Viewport类,给定一个Player和一张牌列表,可以确定哪些牌可见。

我以前从未玩过带有“视口”的卡片,所以我不知道该类应该封装什么。我已经玩卡牌一对夫妇的甲板,一些球员,一个表,霍伊尔的副本。Viewport代表其中哪一项?

但是,这种方法抢夺了Card和Player类的可能的成员函数。

好!

一旦完成了除卡片可见性之外的其他工作,您将剩下Card和Player类,它们仅包含数据,因为所有功能都是在其他类中实现的,这些类大多是没有数据的类,只是方法,如上面的Viewport。这显然与OOP的主要思想背道而驰。

没有; OOP的基本思想是对象封装了它们的关注点。在您的系统中,卡并不太在乎。也不是玩家。 这是因为您正在准确地建模世界。在现实世界中,与游戏相关的纸牌属性极其简单。我们可以用从1到52的数字替换卡片上的图片,而无需很大程度地改变游戏的玩法。我们可以用标有北,南,东和西的人体模型代替这四个人,而无需改变游戏的玩法。 玩家和纸牌是纸牌游戏世界中最简单的事物。 规则是很复杂的,因此代表规则的类就是复杂度所在。

现在,如果您的玩家之一是AI,则其内部状态可能会非常复杂。但是,人工智能无法确定它是否可以看到卡片。规则决定了

这是我设计系统的方式。

首先,如果游戏中有多个卡组,则纸牌会非常复杂。您必须考虑以下问题:玩家可以区分相同等级的两张卡吗?如果第一个玩家玩了七个心中的一个,然后发生了一些事情,然后第二个玩家玩了七个心中的一个,那么第三个玩家可以确定它是相同的七个心吗?请仔细考虑这一点。但是除了这种担心,卡片应该非常简单。它们只是数据。

接下来,玩家的本质是什么?玩家消耗一系列可见的动作产生一个动作

规则对象是协调所有这些的对象。规则产生一系列可见的动作并通知玩家:

  • 一号玩家,十颗心已被三号玩家交给您。
  • 第二名玩家,第三名玩家将一张牌交给了第一名玩家。

然后要求玩家采取行动。

  • 玩家一,你想做什么?
  • 玩家一说:三分。
  • 玩家一,这是非法行为,因为三倍的首发会产生不可抗拒的吸引力。
  • 玩家一,你想做什么?
  • 玩家一说:丢掉黑桃皇后。
  • 玩家二,玩家一丢弃了黑桃皇后。

等等。

将您的机制与策略分开。游戏的策略应该封装在策略对象中,而不是卡中。卡只是一种机制。


41
@gnat:从阿兰凯一个对比的看法是“其实我做了术语‘’我可以告诉你,我没有C ++的想法。”面向对象有没有类面向对象的语言; JavaScript浮现在脑海。
埃里克·利珀特

19
@gnat:我同意今天的JS并不是OOP语言的一个很好的例子,但是它表明人们可以很容易地构建没有类的OO语言。我同意Eiffel和C ++中的OO-ness的基本单位都是类。我不同意的观点是类是OO的必要条件。的必要条件 OO的是一个封装的行为,并通过良好定义的公共接口彼此通信的对象。
埃里克·利珀特

16
我同意@EricLippert,类不是OO的基础,继承也不是主流所能说的。数据,行为和责任的封装已经完成。除了Javascript,还有一些基于原型的语言,它们是面向对象的,但没有类。专注于继承这些概念是特别错误的。也就是说,类是组织行为封装的非常有用的方法。您可以将类视为对象(在原型语言中则相反),这会使行变得模糊。
Schwern 2014年

6
这样考虑:在现实世界中,一张实际的卡牌会表现出什么行为?我认为答案是“无”。卡上还有其他东西。该卡本身,在现实世界中,字面上唯一的信息(俱乐部4),有没有任何形式的内在行为。该信息(又名“卡”)的使用方式取决于某物/某人(即“规则”和“玩家”)的使用率是100%。相同的牌可由任意数量的不同玩家用于无限(很好,也许不是很)多种不同的游戏。卡只是卡,它拥有的只是属性。
Craig 2014年

5
@蒙塔吉斯特:让我再澄清一下。考虑一下C。我想您会同意C没有类。但是,您可以说结构是“类”,可以创建函数指针类型的字段,可以构建vtable,可以创建用于建立vtable的称为“构造函数”的方法,以便某些结构彼此“继承”,等等。您可以在C中模拟基于类的继承。也可以在JS中模拟它。但是,这样做意味着在尚不存在的语言之上构建一些东西。
Eric Lippert 2014年

29

您认为数据和行为的耦合是OOP的中心思想是正确的,但是还有更多。例如,封装:OOP /模块化编程使我们能够将公共接口与实现细节分开。在OOP中,这意味着永远不应公开访问数据,而只能通过访问器使用。根据这个定义,没有任何方法的对象确实是无用的。

除了访问器之外没有其他方法的类本质上是一个过于复杂的结构。但这还不错,因为OOP使您可以灵活地更改内部细节,而结构没有。例如,代替在成员字段中存储值,可以每次重新计算它。或更改后备算法,并随之跟踪必须跟踪的状态。

尽管OOP具有一些明显的优势(尤其是相对于普通的程序编程),但为“纯”的OOP奋斗是幼稚的。有些问题无法很好地映射到面向对象的方法,而其他范式则更容易解决。遇到此类问题时,请不要坚持劣质方法。

  • 考虑以面向对象的方式计算斐波那契数列。我想不出一个明智的方法来做到这一点。简单的结构化编程为该问题提供了最佳解决方案。

  • 您的isVisible关系属于这两个类,或者都不属于这两个类,或者实际上属于context。无行为记录是功能或过程编程方法的典型代表,似乎最适合您的问题。没有错

    static boolean isVisible(Card c, Player p);
    

    而且Card没有方法ranksuit访问器之外的方法也没有错。


11
@UMad是的,这正是我的意思,这没有错。对当前的工作使用正确的<del> language </ del> 范例。(顺便说一下,除了Smalltalk之外,大多数语言都不是纯粹的面向对象的。例如,Java,C#和C ++支持命令式,结构化,过程化,模块化,函数式和面向对象的编程。所有这些非OO范例都是有原因的。 :以便您可以使用它们)
amon

1
有一种明智的OO方法可以执行Fibonacci,fibonacci在的实例上调用方法integer。我希望强调您的观点,即OO是关于封装的,即使在看起来很小的地方也是如此。让整数找出如何完成这项工作。稍后您可以改进实现,添加缓存以提高性能。与函数不同,方法遵循数据,因此所有调用方都可以从改进的实现中受益。也许以后会添加任意精度整数,它们可以像普通整数一样透明地对待,并且可以有自己的性能调整fibonacci方法。
Schwern 2014年

2
@Schwern如果任何东西Fibonacci是抽象类的子类Sequence,则序列可被任何数字集使用,并负责存储种子,状态,缓存和迭代器。
乔治·瑞斯

2
我没想到“纯正的OOP斐波那契”在书呆子狙击中如此有效。尽管有一定的娱乐价值,但请停止在这些评论中进行任何圆形讨论。现在,让我们共同为变革做些有建设性的事情!
阿蒙2014年

3
使fibonacci成为整数方法是很愚蠢的,只是您可以说它是OOP。这是一个函数,应将其视为函数。
immibis 2014年

19

OOP背后的基本思想是数据和行为(基于数据)是不可分割的,并且它们与类对象的思想联系在一起。对象具有与之配合使用的数据和方法(以及其他数据)。显然,根据OOP的原理,仅仅是数据的对象(如C结构)被视为反模式。(...)这显然与OOP的基本思想背道而驰。

这是一个棘手的问题,因为它基于许多错误的前提:

  1. OOP是编写代码的唯一有效方法。
  2. OOP是一个定义明确的概念。这已经成为一个流行语,很难找到两个可以就OOP达成共识的人。
  3. OOP是关于捆绑数据和行为的想法。
  4. 一切都是/应该是抽象的想法。

我不会多谈#1-3,因为每个人都可以产生自己的答案,并且会引起很多基于意见的讨论。但是我发现“ OOP是关于耦合数据和行为”的想法特别麻烦。它不仅导致了#4,而且还导致了一切都应该是方法的想法。

定义类型的操作与使用该类型的方法之间存在差异。能够检索ith元素对于数组的概念至关重要,但是排序只是我可以选择处理的众多事情之一。排序不需要是“仅创建一个包含偶数元素的新数组”的方法。

OOP是关于使用对象的。对象只是实现抽象的一种方法。抽象是一种避免代码中不必要耦合的方法,而不是最终目的。如果您仅通过套房和等级的值来定义卡的概念,则可以将其实现为简单的元组或记录。没有任何其他代码部分可以依赖的非必要细节。有时候,您什么都没有藏起来。

您不会制作isVisible这种Card类型的方法,因为对于卡的概念来说,可见并不是很重要的(除非您有非常特殊的卡,它们可以变成半透明或不透明...)。它应该是这种Player类型的方法吗?好吧,这可能也不是决定球员素质的因素。它应该是某种Viewport类型的一部分吗?再次取决于您定义的视口以及检查卡可见性的概念是否对定义视口必不可少。

它很可能isVisible应该只是一个自由功能。


1
+1是常识,而不是无意识的开车。
2014年

从我读过的书中,您链接的文章看起来像是我一段时间以来没有读过的一本坚实的书。
Arthur Havlicek 2014年

@ArthurHavlicek如果您不理解代码示例中使用的语言,则很难遵循,但是我发现它很有启发性。
2014年

9

显然,根据OOP的原理,仅仅是数据的对象(如C结构)被视为反模式。

不,不是。Plain-Old-Data对象是一种完全有效的模式,我希望它们在处理需要在程序的不同区域之间进行持久化或通信的数据的任何程序中。

虽然您的数据层在从表中读取数据时可能会假脱机一个完整的PlayerPlayers,但它可能只是一个通用数据库,该数据库返回包含表中字段的POD,并将其传递到程序的另一个区域,该区域将进行转换玩家POD到您的具体Player班级。

在程序中使用有类型的或无类型的数据对象可能没有意义,但这并不能使它们成为反模式。如果它们有意义,请使用它们,否则请不要使用。


5
不要不同意您所说的话,但这根本无法回答问题。就是说,我将问题归咎于答案。
pdr

2
确切地说,即使在现实世界中,卡片和文档也只是信息的容器,任何无法处理的“模式”都应被忽略。
JeffO 2014年

1
Plain-Old-Data objects are a perfectly valid pattern 我不是说不是,而是说当他们填充应用程序的整个下半部分时,这是错误的。
RokL 2014年

8

我个人认为域驱动设计有助于使这个问题更加清晰。我问的问题是,我该如何向人类描述纸牌游戏?换句话说,我在建模什么?如果我要建模的东西确实包含“视口”一词和与其行为相匹配的概念,那么我将创建视口对象并使其在逻辑上应做的事情。

但是,如果我在游戏中没有视口的概念,那是我认为需要的东西,因为否则代码“感觉不对”。对于将其添加到我的域模型中,我三思而后行。

单词模型意味着您正在构建事物的表示形式。我警告不要放置一个代表抽象事物的类,而不是您代表的事物。

我将进行编辑以补充说明,如果需要与显示器进行交互,则可能需要在代码的另一部分中使用视口的概念。但是以DDD术语来说,这将是基础架构方面的问题,并且将存在于域模型之外。


利珀特(Lippert)的上述回答是该概念的一个更好的例子。
RibaldEddie'4

5

我通常不做自我宣传,但事实是我在博客上写了很多有关OOP设计的文章。总结几页:您不应该从类开始设计。从接口或API以及形状代码开始,有更大的机会提供有意义的抽象,符合规范并避免使用不可重用的代码膨胀具体的类。

如何申请Card- Player问题:创建一个ViewPort抽象有道理的,如果你想CardPlayer为两个独立的库(这将意味着Player没有有时使用Card)。但是,我倾向于考虑Player保留Cards并应为其提供Collection<Card> getVisibleCards ()访问器。就创建可理解的代码关系而言,这两种解决方案(ViewPort和我的解决方案)都比isVisible作为Card或提供的方法更好Player

一流的解决方案对于而言要好得多DocumentId。几乎没有动机使(基本上是整数)依赖于复杂的数据库库。


我喜欢你的博客。
RokL 2014年

3

我不确定眼前的问题是否在正确的水平上得到了回答。我曾敦促论坛中的智者在这里积极思考问题的核心。

U Mad提出一种情况,他认为按照他对OOP的理解进行编程通常会导致许多叶子节点成为数据持有者,而他的上层API则是大多数行为。

我认为这个话题与CardVis Player是否定义isVisible有关;这只是一个例子,尽管很幼稚。

不过,我已邀请经验丰富的人员来研究眼前的问题。我认为,U Mad提出了一个很好的问题。我知道您会将规则和有关的逻辑推到自己的对象上;但据我了解,问题是

  1. 具有简单的数据持有者构造(类/结构;我不在乎它们针对此问题建模)是否可以提供真正的功能?
  2. 如果是,对它们进行建模的最佳或首选方式是什么?
  3. 如果没有,我们如何将这个数据计数器部件合并到更高的API类中(包括行为)

我的观点:

我认为您是在问一个粒度问题,在面向对象的编程中很难做到这一点。以我的少量经验,我不会在模型中包括一个本身不包含任何行为的实体。如果必须的话,我可能已经使用了一种结构,该结构旨在保留这种抽象,而不像具有封装数据和行为的类的类。


3
问题是问题(和您的回答)是关于“一般”如何做事的。事实是,我们从不 “一般”地做事。我们总是做特定的事情。有必要检查我们的特定事物并根据我们的要求对其进行衡量,以确定我们的特定事物是否适合该情况。
约翰·桑德斯

@JohnSaunders我在这里理解您的智慧并在一定程度上表示同意,但是在解决问题之前也只需要概念上的方法。毕竟,这里的问题并不像看起来那样开放。我认为这是任何面向对象设计人员在OOP最初使用中都会遇到的有效的面向对象问题。你拿什么 如果有帮助,我们可以讨论构建您选择的示例。
哈莎2014年

我已经放学超过35年了。在现实世界中,我发现“概念方法”几乎没有价值。在这种情况下,我发现经验是比Meyers更好的老师。
约翰·桑德斯

我不太了解数据类与行为区分类。如果正确地抽象对象,则没有区别。想象一个Point带有getX()功能的东西。您可以想象它正在获得它的属性之一,但是它也可以从磁盘或互联网读取它。获取和设置是行为,拥有可以做到这一点的类是完全可以的。数据库只能获取和设置数据-Arthur
Havlicek

@ArthurHavlicek:知道一个班级不会做什么通常和知道它将做什么一样有用。在其合同中指定某些内容,使其仅充当可共享的不可变数据持有人,或充当不可共享的可变数据持有人,这很有用。
supercat 2014年

2

OOP中常见的混乱根源在于这样一个事实,即许多对象封装了状态的两个方面:它们了解的事物以及了解它们的事物。关于对象状态的讨论经常忽略后一个方面,因为在对象引用混杂的框架中,没有通用的方法来确定任何有关其引用曾经暴露于外界的对象可能知道的事情。

我建议拥有一个CardEntity将卡的那些方面封装在单独组件中的对象可能会有所帮助。一个组成部分与卡上的标记有关(例如,“钻石王”或“熔岩爆炸;玩家有AC-3闪避几率,否则会受到2D6伤害”)。一个人可能与状态的独特方面有关,例如位置(例如,它在甲板上,在乔的手中,或者在拉里前面的桌子上)。可能涉及到三分之一的人可以看到它(也许没有人,也许一个玩家,或者很多玩家)。为了确保所有内容保持同步,可能不会将卡封装为简单字段,而是封装为CardSpace对象。将卡片移到某个空间,可以参考CardSpace宾语; 然后它将自己从旧空间中移出,并放入新空间中)。

显式地将“谁知道X”与“ X知道”分开封装,应该有助于避免很多混乱。有时需要小心避免内存泄漏,尤其是在有许多关联的情况下(例如,如果新卡可以存在而旧卡消失了,则必须确保不要将应丢弃的卡永久地附着在任何长寿命的物体上) ),但如果对对象的引用的存在将构成其状态的相关部分,则对象本身明确地封装此类信息是完全适当的(即使将实际管理它的工作委派给其他类)。


0

但是,这种方法抢夺了Card和Player类的可能的成员函数。

那该如何做?

要使用与您的卡片示例类似的类比,请考虑Cara Driver和a ,然后确定是否Driver可以驱动Car

好的,所以您决定不希望自己Car知道Driver车钥匙是否正确,并且由于某些未知的原因,您还决定不希望Driver了解Car班级(您没有充实的肉体)以及您最初提出的问题)。因此,您有一个中间类,类似于类的中间部分Utils,其中包含带有业务规则的方法,以便boolean为上述问题返回值。

我认为这很好。中级班级可能现在只需要检查车钥匙,但是可以重构以考虑驾驶员是否在酒精的影响下或在反乌托邦的将来拥有有效的驾驶执照,以检查DNA生物特征。通过封装,将这三个类并存在一起确实没有什么大问题。

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.