为什么我们要传递对象而不是对象成员到函数中?

12

我有一个在类 A 中的方法 FOO(),它的参数包括来自于类 B 的数据成员和其他东西(假设它们是两个浮点数和一个整数)。根据我的理解,通常最好使用以下方式实现:

A->FOO1(B, other_data_x)

而不是

A->FOO2(B.member1, B.member2, B.member3, other_data_x).

我了解到这种方法的一个优点(但不是唯一的优点)是,它将访问B的哪些成员的细节留给了FOO1(),从而有助于隐藏实现细节。

但我想知道的是,这是否确实在类AB之间引入了额外的耦合。在前一种情况下,类A必须知道类B的存在(通过诸如include class_B_header.h之类的方式),如果B的成员发生更改或被移动到其他类中,或者类B被完全删除,则必须相应地修改AFOO1()。相比之下,在后一种方法中,FOO2()并不关心类B是否存在,事实上,它所关心的只是它提供了两个浮点数(在本例中包括B.member1B.member2)和一个整数(B.member3)。当然,在后一种示例中也存在耦合,但这种耦合由调用FOO2()的任何类或正在调用FOO2()的类处理,而不是在AB的定义中。

我想问的第二部分问题是,是否有良好的方法可以进一步解耦AB,当我们想要实现类似FOO1()的解决方案时?


2
你传递的是值而不是类。 - Captain Obvlious
8个回答

18

但我想知道的是,这是否会在类A和B之间引入更多的耦合?

是的,会。现在AB紧密耦合。

你好像认为普遍认为应该传递对象而不是对象的成员变量。我不确定你是如何得出这个结论的,但事实并非如此。传递一个对象或者该对象的成员变量完全取决于你所想要做的事情。

在某些情况下,两个实体之间需要严密的依赖关系,而在其他情况下则不需要。如果有一般性的经验法则适用于此处,我会说它恰恰相反于你提出的建议:

尽可能消除依赖关系,但不在无需消除的地方。


13

没有普遍正确或错误的选择,这基本上是一个问题,取决于哪个更能反映您真正的意图(这使用元句法变量无法猜测)。

例如,如果我编写了一个“read_person_data”函数,它应该将某种“person”对象作为它要读取数据的目标。

另一方面,如果我有一个“read two strings and an int”函数,它应该用两个字符串和一个int,即使(例如)我正在使用它来读取名字和姓氏以及员工号(即一个人数据的一部分)。

但有两个基本要点:首先,像前者这样做一些有意义和逻辑的事情通常比像后者这样几乎只是任意集合的操作更可取,这些操作偶然发生在一起。

其次,如果您有这样的情况,则可以质疑所讨论的函数是否不应该(至少逻辑上)成为您的A的一部分,而不是作为对A进行操作的单独的东西。这并不确定,但您可能只是在看待相关类之间的分割较差。


5
欢迎来到工程世界,这里一切都是妥协,正确的解决方案取决于您的类和函数应该如何使用(以及它们的含义应该是什么)。
如果 foo() 在概念上依赖于一个 float、一个 int 和一个 string 的结果,那么让它接受一个 float、一个 int 和一个 string 是正确的。无论这些值来自类的成员(可能是 B,但也可能是 CD),因为从语义上讲 foo() 不是按照 B 来定义的。
另一方面,如果 foo() 在概念上依赖于 B 的状态 - 例如,因为它实现了对该状态的抽象 - 那么让它接受类型为 B 的对象。
现在,一个好的编程实践是尽可能让函数接受少量的参数,我会说最多三个,所以如果一个函数在逻辑上与多个值一起工作,您可能希望将这些值分组在数据结构中,并将该数据结构的实例传递给 foo()
现在,如果您将该数据结构称为 B,我们又回到了最初的问题 - 但是有了一个语义上的差别!
因此,foo() 是否应接受三个值或 B 的实例大多取决于在您的程序中 foo()B 具体意味着什么,以及它们将如何使用 - 它们的能力和责任是否逻辑上耦合。

3
鉴于这个问题,你可能对类的理解有误。在使用类时,你应该只关心其公共接口(通常仅包括方法),而不是其数据成员。数据成员通常应该是类的私有部分,因此你根本无法访问它们。
将类视为物理对象,例如球,你可以看到球是红色的,但你不能简单地将球的颜色设置为蓝色。你需要对球进行一些操作才能使其变成蓝色,例如给它涂色。
回到你的类:为了让A对B执行某些操作,A必须知道一些关于B的信息(例如,需要给球涂色来改变其颜色)。
如果你想让A与除B类之外的其他对象一起工作,可以使用继承将A所需的接口提取到I类中,然后让类B和其他类C继承I。现在,A可以同样有效地使用类B和C。

2
我认为这取决于你要实现什么。把类的一些成员传递给函数并没有错。这主要取决于"FOO1"的含义——在使用other_data_xB进行FOO1操作是否能更清晰地表达你想做的事情。
举个例子,如果我们用可以理解意义的"真实"名称来代替任意名称A、B、FOO等等:
enum Shapetype
{
   Circle, Square
};

enum ShapeColour
{
   Red, Green, Blue
}

class Shape 
{ 
 public:
   Shape(ShapeType type, int x, int y, ShapeColour c) :
         x(x), y(y), type(type), colour(c) {}
   ShapeType type;
   int x, y;
   ShapeColour colour;
   ...
};


class Renderer
{
   ... 
   DrawObject1(const Shape &s, float magnification); 
   DrawObject2(ShapeType s, int x, int y, ShapeColour c, float magnification); 
};


int main()
{
   Renderer r(...);
   Shape c(Circle, 10, 10, Red);
   Shape s(Square, 20, 20, Green);

   r.DrawObject1(c, 1.0);
   r.DrawObject1(s, 2.0);
   // ---- or ---
   r.DrawObject2(c.type, c.x, c.y, c.colour, 1.0);
   r.DrawObject2(s.type, s.x, s.y, s.colour, 1.0);
};
< p >【是的,这个例子还是有点愚蠢,但讨论主题时对象具有真实名称会更有意义】

DrawObject1需要了解关于形状的所有信息,如果我们开始重新组织数据结构(将xy存储在一个名为point的成员变量中),它必须进行更改。但这可能只是一些小的改变。

另一方面,在DrawObject2中,我们可以随心所欲地重新组织形状类 - 甚至完全删除它,并将x、y、形状和颜色分别存储在不同的向量中[虽然这不是特别好的想法,但如果我们认为这解决了某个问题,那么我们可以这样做]。

这在很大程度上取决于什么最有意义。在我的例子中,将Shape对象传递给DrawObject1可能是有意义的。但这并不确定,并且肯定有很多情况下您不希望这样做。


2

为什么要传递一个类而不是单个成员变量有几个原因:

  1. 取决于所调用的函数对参数的操作,如果参数足够隔离,我会传递成员变量。
  2. 在某些情况下,您可能需要传递大量的变量。此时,将它们打包到单个对象中并传输该对象更加高效。
  3. 封装性。如果需要将这些值相互关联起来,那么可以创建一个处理该关系的类,而不是在需要其中一个成员时在代码中处理它。

如果您担心依赖关系,则可以实现接口(例如在Java中或使用抽象类的C++中相同)。通过这种方式,您可以减少对特定对象的依赖性,但确保它可以处理所需的API。


1
为什么要传递对象而不是基本类型?
我们之所以传递对象而不是基本类型,是因为我们希望建立清晰的上下文,区分特定的观点,并表达明确的意图。
选择传递基本类型而不是完整的、丰富的、有上下文的对象会降低抽象程度并扩大函数的范围。有时这些是目标,但通常是要避免的事情。低内聚是一种负债,而不是一种资产。
与通过完整的、丰富的对象操作相关事物不同,通过基本类型,我们现在只操作任意值,这些值可能与彼此严格相关,也可能不相关。没有定义的上下文,我们不知道组合的基本类型是否在任何时候都有效,更不用说在系统当前状态下是否有效了。当我们在函数定义时选择基本类型而不是丰富的对象时,任何丰富上下文对象提供的保护都会丢失(或在错误的位置重复),进一步地,当使用简单的基本类型时,我们通常会引发、观察和响应的任何事件或信号都很难在正确的时间引发和跟踪。
使用对象而不是基本数据类型可以增加内聚性。内聚性是一个目标,属于一起的事物应该保持在一起。通过清晰的上下文尊重函数对参数的分组、排序和交互的自然依赖关系是一件好事。
使用对象而不是基本数据类型不一定会增加我们最担心的耦合度。我们最应该担心的耦合度是外部调用者决定消息的形状和顺序的耦合度,以及我们进一步向他们规定的规则唯一的方式出售的耦合度。
相比于全力以赴,我们应该注意到一个明显的“线索”。中间件供应商和服务提供商肯定希望我们全力以赴,并将其系统与我们的系统紧密集成。他们想要建立牢固的依赖关系,与我们自己的代码紧密交织在一起,这样我们就会不断回来。然而,我们比这聪明。即使不够聪明,我们也可能有足够的经验,因为我们已经走过了那条路,知道接下来会发生什么。我们不会通过允许供应商代码的元素侵入每个角落来提高出价,因为我们知道我们无法购买这手牌,因为他们坐在一堆筹码上,老实说,我们的手牌并不好。相反,我们会说这是我要用你的中间件做的事情,并铺设一个有限的、适应性的接口,允许游戏继续进行,但不会把所有的筹码都压在这一手上。我们这样做是因为在下一手牌中,我们可能会面对不同的中间件供应商或服务提供商。
撇开特别的扑克隐喻不谈,当耦合出现时,逃避耦合的想法会让你付出代价。如果你打算长期参与游戏,并且有倾向于与其他供应商、提供者或设备打交道,而这些你无法控制,那么逃避最紧密和昂贵的耦合可能是明智的做法。

关于上下文对象与原始数据类型的使用,我还可以说很多,比如在提供有意义、灵活的测试方面。但是,我建议从像“叔叔”Bob Martin这样的作者那里阅读有关编码风格的特定文章,但现在我会省略它们。


0

同意第一位回答者的观点,关于你提出的“是否有其他方法”的问题;

由于你将类B的实例传递给A的方法FOO1,因此可以合理地假设B提供的功能并非完全独特,即可能有其他实现B所提供的A的方式。(如果B是一个没有自己逻辑的POD,那么尝试将它们解耦甚至没有意义,因为A需要了解B的很多信息)。

因此,您可以通过将B为A提供的任何内容提升到一个接口,从而使A与B的内部细节分离,并在A中包含“BsInterface.h”文件。这假设可能存在C、D...以及其他变体来完成B为A所做的事情。 如果没有,则必须问自己为什么B首先是A之外的一个类...

归根结底,所有的一切都归结为一件事;它必须讲得通...

每当我遇到哲学辩论时(主要是和自己),我总是从另一个角度来看待问题;我会如何使用A->FOO1?在调用代码中,处理B是否有意义?因为A和B的使用方式,我是否总是不得不将A和B一起包含在一起?

也就是说,如果你想写出干净的代码(+1),那么退一步并应用“保持简单”的规则,但始终允许可用性优先于任何过度设计代码的诱惑。

这就是我的观点。


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