设计一个类以将整个类作为参数而不是单个属性


30

举例来说,假设您有一个应用程序,该应用程序具有广泛共享的类,称为User。此类公开有关用户,其ID,名称,对每个模块的访问级别,时区等所有信息。

用户数据显然在整个系统中得到了广泛的引用,但是无论出于何种原因,都对系统进行了设置,因此,我们不是在将用户对象传递到依赖于该对象的类中,而是从其中传递了各个属性。

需要用户ID的类仅需要GUID userId作为参数,有时我们可能还需要用户名,因此将其作为单独的参数传递。在某些情况下,这将传递给各个方法,因此这些值根本不会保存在类级别。

每当我需要从User类访问不同的信息时,我都必须通过添加参数进行更改,而在不适合添加新的重载的地方,我也必须更改对方法或类构造函数的每个引用。

用户只是一个例子。这在我们的代码中得到了广泛的实践。

我认为这违反了开放/封闭原则,对吗?不仅是更改现有类的行为,而且是首先设置它们,以便将来很有可能需要进行广泛的更改吗?

如果我们只是传入User对象,则可以对正在使用的类进行一些小的更改。如果必须添加参数,则可能不得不对类的引用进行数十次更改。

这种做法是否违反了其他任何原则?依赖倒置也许?尽管我们没有引用抽象,但是只有一种类型的用户,因此并没有真正需要User界面的需求。

是否还有其他违反非Solid的原则,例如基本的防御性编程原则?

我的构造函数应如下所示:

MyConstructor(GUID userid, String username)

或这个:

MyConstructor(User theUser)

发表编辑:

已经建议在“密码或对象?”中回答问题。这并未回答以下问题的问题:无论哪种方式的决定都会如何影响遵循SOLID原则的尝试,而SOLID原则是此问题的核心。


11
@gnat:绝对不是重复的。可能的重复是关于方法链接以深入到对象层次结构。这个问题似乎根本没有问这个问题。
格雷格·伯格哈特

2
当传递的参数数量变得笨拙时,通常使用第二种形式。
罗伯特·哈维

12
关于第一个签名,我不喜欢的一件事是无法保证userId和username实际上源自同一用户。通过到处传播用户可以避免此潜在的错误。但是决定实际上取决于被调用方法对参数的处理方式。
18年

9
在您使用的上下文中,“ parse”一词毫无意义。您是说“通过”吗?
康拉德·鲁道夫

5
怎么样ISOLIDMyConstructor现在基本上说“我需要一个Guid和一个string”。那么,为什么没有提供的接口Guidstring,让User实现该接口,让MyConstructor依赖于实现该接口的实例?并且如果需要MyConstructor更改,请更改界面。-它极大地帮助我想到了“属于” 消费者而不是提供者的接口。因此,请考虑“作为消费者,我需要做到这一点和那件事”,而不是“作为提供者,我可以做到这一点和那件事”。
科拉克

Answers:


31

将整个User对象作为参数传递绝对没有错。实际上,这可能有助于澄清您的代码,并且如果方法签名需要,则使程序员更清楚地了解方法所采用的方法User

传递简单的数据类型是件好事,直到它们表示的不是它们本身。考虑以下示例:

public class Foo
{
    public void Bar(int userId)
    {
        // ...
    }
}

以及示例用法:

var user = blogPostRepository.Find(32);
var foo = new Foo();

foo.Bar(user.Id);

你能发现缺陷吗?编译器不能。传递的“用户ID”只是一个整数。我们命名该变量,user但从blogPostRepository对象初始化其值,该对象可能会返回BlogPost对象,而不是User对象-但是代码会编译,最终会导致运行时错误。

现在考虑以下示例:

public class Foo
{
    public void Bar(User user)
    {
        // ...
    }
}

也许Bar方法仅使用“用户ID”,但方法签名需要一个User对象。现在,让我们回到与以前相同的示例用法,但将其修改为将整个“用户”传递给:

var user = blogPostRepository.Find(32);
var foo = new Foo();

foo.Bar(user);

现在我们有一个编译器错误。该blogPostRepository.Find方法返回一个BlogPost对象,我们巧妙地将其称为“用户”。然后,我们将此“用户”传递给Bar方法,并迅速得到编译器错误,因为我们无法将a传递BlogPost给接受a的方法User

该语言的类型系统正被利用来更快地编写正确的代码,并在编译时而不是运行时识别缺陷。

实际上,由于用户信息的更改而不得不重构大量代码仅仅是其他问题的征兆。通过传递整个User对象,除了在User类的某些内容发生更改时不必重构所有接受用户信息的方法签名之外,还具有上述好处。


6
我想说的是,您的推理本身实际上指向传递的字段,但让字段成为围绕实际价值的琐碎包装。在此示例中,用户具有类型为UserID的字段,而用户ID具有单个整数值的字段。现在,Bar的声明立即告诉您Bar不会使用有关User的所有信息,仅使用其ID,但是您仍然不会犯任何愚蠢的错误,例如将不是来自UserID的整数传递给Bar。
伊恩

(续)当然,这种编程风格非常繁琐,尤其是在没有很好的语法支持的语言中(例如,Haskell对此风格很好,因为您可以仅在“ UserID id”上进行匹配) 。
伊恩

5
@Ian:我认为应该将ID包裹在自己的类型溜冰鞋中,以解决由OP引起的原始问题,这是对User类的结构性更改,因此有必要重构许多方法签名。传递整个User对象可以解决此问题。
格雷格·伯格哈特

@Ian:虽然说实话,但是即使在C#中工作,我也非常想将Ids和排序包装在Struct中,只是为了使内容更加清晰。
格雷格·伯格哈特

1
“在其位置传递指针没有错。” 或参考,以避免可能遇到的指针问题。
Yay295

17

我认为这违反了开放/封闭原则,对吗?

不,这不违反该原则。该原则与不User以影响使用该代码的代码其他部分的方式进行更改有关。您对所做的更改User可能是这样的违反,但没有关系。

这种做法是否违反了其他任何原则?依赖倒置可能吗?

不。您所描述的-只是将用户对象的必需部分注入每个方法中-恰恰相反:它是纯粹的依赖关系反转。

是否还有其他违反非Solid的原则,例如基本的防御性编程原则?

不可以。这种方法是一种完全有效的编码方法。它没有违反这些原则。

但是依赖倒置只是一个原则。这不是牢不可破的法律。纯DI会增加系统的复杂性。如果发现仅将所需的用户值注入方法中,而不是将整个用户对象传递给方法或构造函数中的方法会产生问题,则不要那样做。这是要在原则和实用主义之间取得平衡。

要发表您的评论:

必须不必要地在链的五个级别下解析新值,然后将所有引用更改为所有这些现有方法中的所有五个方法存在问题。

这里的部分问题是,根据“不必要的[通过] ...”注释,您显然不喜欢这种方法。这很公平;这里没有正确的答案。如果您觉得它很麻烦,那就不要那样做。

但是,对于开放式/封闭式原则,如果严格遵循开放式/封闭式原则,则“ ...将所有引用更改为所有这些五个现有方法...”表示这些方法在应修改时已被修改。禁止修改。但实际上,开放/封闭原则对于公共API来说很有意义,但对应用程序的内部而言却没有太大意义。

...但是,可以肯定的是,在切实可行的范围内遵守该原则的计划将包括减少未来变更需求的策略吗?

但是随后您进入了YAGNI领域,它仍然与该原理正交。如果您有Foo一个使用用户名的方法,然后又想Foo使用一个生日,则遵循该原理,可以添加一个新方法;Foo保持不变。再次,对于公共API来说这是一个好习惯,但是对于内部代码却是胡扯。

如前所述,在任何给定情况下,这都是平衡和常识。如果这些参数经常更改,则可以,User直接使用。它将避免您描述的大规模更改。但是,如果它们不经常更改,那么仅传递需要的内容也是一种很好的方法。


必须不必在链的五个层次上解析新值,然后将所有引用更改为所有这些现有方法中的所有五个,这存在问题。为什么“打开/关闭”原理仅适用于User类,而不适用于我当前正在编辑的类,其他类也使用该类?我知道该原则专门用于避免更改,但是一定要在切实可行的范围内遵守该原则的计划中包括减少未来更改需求的策略?
Jimbo

@Jimbo,我已经更新了我的答案,以尝试解决您的评论。
David Arno

感谢您的贡献。顺便说一句。甚至罗伯特·C·马丁(Robert C Martin)都不接受开放/封闭原则有硬性规定。这是一条经验法则,将不可避免地被打破。应用该原则是一种尝试,在可行的范围内尽可能多地遵守该原则。这就是为什么我以前使用“可行”这个词的原因。
Jimbo,

传递User的参数而不是User本身不是依赖转换。
James Ellis-Jones

@ JamesEllis-Jones,依赖性反转将依赖性从“询问”翻转为“告诉”。如果传入一个User实例,然后查询该对象以获取一个参数,那么您将仅部分反转依赖关系;否则,您将发现依赖关系。仍然有人问。真正的依赖倒置是100%“告诉,不要问”。但这要付出复杂的代价。
David Arno

10

是的,更改现有功能违反了开放/封闭原则。您正在修改由于需求变更而应禁止修改的内容。更好的设计(在需求更改时不要更改)是将User传递给应该对用户起作用的事物。

但是,这可能会遇到触犯接口分离原则的,因为你可以一起传递方式的详细信息比功能,需要做的工作。

因此,与大多数情况一样,这取决于

仅使用用户名,让我们的功能更加灵活,无论用户名来自何处,都可以使用用户名,而无需创建功能全面的User对象。如果您认为数据源将改变,它提供了改变的弹性。

使用整个用户可​​以使用法更加清晰,并与呼叫者签订更牢固的合同。如果您认为需要更多的用户,它可以提供变化的弹性。


+1,但我不确定您的说法“您可能会传递更多信息”。当您传递(用户theuser)时,您传递的信息非常少,即对一个对象的引用。诚然,可以使用引用来获取更多信息,但这意味着调用代码不必获取它。当传递(GUID用户ID,字符串用户名)时,被调用方法可以始终调用User.find(userid)来查找对象的公共接口,因此您实际上并没有隐藏任何东西。
dcorking

5
@dcorking,“ 当您传递(用户 theuser )时,您传递的信息非常少,即引用一个对象 ”。您传递与该对象有关的最大信息:整个对象。“ 被调用的方法可以始终调用User.find(userid) ...”。在设计良好的系统中,这将是不可能的,因为所讨论的方法将无法访问User.find()。实际上,甚至不应该一个User.find。寻找使用者绝不是的责任User
大卫·阿诺

2
@dcorking-恩。您传递的引用恰巧很小,这是技术上的巧合。您正在将整个耦合User到函数。也许这是有道理的。但是也许函数应该只在乎用户名-并且传递诸如用户的加入日期或地址不正确的信息。
Telastyn

@DavidArno也许这是明确回答OP的关键。寻找用户应由谁负责?将取景器/工厂与班级分开的设计原理是否有名称?
浮冰

1
@dcorking我要说的是“单一责任原则”的一个含义。知道用户存储在何处以及如何通过ID检索用户是User-class不应承担的单独职责。可能有一个UserRepository或类似的东西来处理这样的事情。
绿巨人

3

该设计遵循参数对象模式。它解决了方法签名中具有许多参数所引起的问题。

我认为这违反了开放/封闭原则,对吗?

否。应用此模式可以启用 打开/关闭原理(OCP)。例如,User可以提供的派生类作为参数,从而在消费类中引起不同的行为。

这种做法是否违反了其他任何原则?

可以发生。让我根据SOLID原理进行解释。

单一职责原则(SRP)可以,如果有,你已经解释的设计被侵犯:

此类公开有关用户,其ID,名称,对每个模块的访问级别,时区等所有信息。

问题出在所有信息上。如果User该类具有许多属性,它将成为一个庞大的数据传输对象,该对象从使用类的角度传输无关的信息。示例:从消费类UserAuthentication的角度来看,属性User.IdUser.Name是相关的,但不是User.Timezone

接口隔离原则(ISP)也违反了与一个类似的推理,但增加了另一个角度。示例:假设使用类UserManagement需要将属性User.Name拆分为该类,User.LastName并且为此也必须修改User.FirstName该类UserAuthentication

幸运的是,ISP还为您提供了解决问题的可能方法:通常,此类参数对象或数据传输对象开始时很小,并且随着时间的推移而增长。如果这变得难以处理,请考虑以下方法:引入适合于消费类需求的接口。示例:介绍接口并让User类从其派生:

class User : IUserAuthenticationInfo, IUserLocationInfo { ... }

每个接口都应公开User消费类完成其操作所需的类相关属性的子集。查找属性簇。尝试重用接口。对于消费类,UserAuthentication使用IUserAuthenticationInfo代替User。然后,如果可能,User使用接口作为“模板” 将类分解为多个具体的类。


1
一旦用户变得复杂,例如,如果用户只有3个属性,就会有7种可能的组合,从而可能会出现子接口的组合爆炸式增长。您的提案听起来不错,但不可行。
user949300

1.从分析上来说你是对的。但是,取决于域的建模方式,相关信息的位倾向于聚集。因此,实际上没有必要处理接口和属性的所有可能组合。2.概述的方法并非旨在成为一种通用解决方案,但也许我应该在答案中添加一些“可能”和“可以”。
Theo Lenndorff

2

当在我自己的代码中遇到这个问题时,我得出结论,基本模型类/对象就是答案。

一个常见的示例是存储库模式。通常,通过存储库查询数据库时,存储库中的许多方法都带有许多相同的参数。

我对存储库的经验法则是:

  • 如果不止一种方法采用相同的2个或更多参数,则应将这些参数组合在一起作为模型对象。

  • 如果一个方法需要两个以上的参数,则应将这些参数组合在一起作为模型对象。

  • 模型可以从一个共同的基础继承,但是只有在真正有意义的情况下才可以继承(通常,重构要比考虑继承开始要晚一些)。


在项目开始变得有点复杂之前,使用来自其他层/区域的模型的问题才变得显而易见。只有这样,您才能发现更少的代码会带来更多的工作或更多的复杂性。

是的,拥有两个具有相同属性并服务于不同层/用途的不同模型(例如ViewModels与POCO)完全没问题。


2

让我们检查一下SOLID的各个方面:

  • 单一责任:如果人们只倾向于绕过班级中的某些部分,则可能违法。
  • 打开/关闭:与传递类的各个部分无关,仅与传递完整对象的地方无关。(我认为这是认知失调的根源:您需要更改遥远的代码,但类本身看起来还不错。)
  • Liskov替换:非问题,我们不做子类。
  • 依赖倒置(取决于抽象,而不是具体数据)。是的,这是违反的:人们没有抽象,他们取出类的具体元素并将其传递给周围。我认为这是这里的主要问题。

容易混淆设计本能的一件事是,该类本质上是用于全局对象的,并且本质上是只读的。在这种情况下,违反抽象并不会带来太大的伤害:仅读取未修改的数据会产生非常弱的耦合;如果没有修改,则会造成严重的耦合。只有当它变得巨大时,痛苦才变得明显。
要恢复设计本能,只需假设该对象不是非常全局。如果需要,函数需要什么上下文User可以随时更改对象,?对象的哪些组成部分可能会一起突变?这些可以拆分出来User,无论是作为引用的子对象还是作为仅公开相关字段“片段”的接口,都没有那么重要。

另一个原则:查看使用以下部分的功能 User并查看哪些字段(属性)倾向于组合在一起。这是一个很好的子对象初步列表-您肯定需要考虑它们是否真正属于一起。

这项工作很繁琐,而且有些困难,而且代码的灵活性会稍差一些,因为识别需要传递给函数的子对象(子接口)变得更加困难,特别是在子对象重叠的情况下。

User如果子对象重叠,拆分实际上会变得很丑陋,那么如果所有必填字段都来自重叠,人们会感到困惑,该选择哪个。如果您按等级划分(例如,您UserMarketSegment拥有UserLocation),那么人们将不确定他们正在编写的功能位于哪个级别:是在该Location 级别还是在该MarketSegment级别处理用户数据?可以随着时间的变化而改变并不能完全帮助您,例如,您回到更改函数签名的地方,有时甚至遍及整个调用链。

换句话说:除非您真的很了解自己的领域并且对哪个模块在处理什么方面有一个清晰的了解,User否则真正不值得改善程序的结构。


1

这是一个非常有趣的问题。这确实取决于。

如果您认为您的方法将来可能会在内部发生变化,从而需要User对象的不同参数,那么您当然应该将整个事情都传递出去。这样做的好处是,可以保护方法外部的代码免受使用的参数方面的方法内部更改的影响,正如您所说的那样,这将导致外部一系列更改。因此,传递整个用户会增加封装。

如果您确定您将不需要使用除用户电子邮件之外的任何方式,则应将其传递给您。这样做的好处是,您可以在更广泛的上下文中使用该方法:例如,可以使用它使用公司的电子邮件或刚刚输入的电子邮件。这增加了灵活性。

这是关于将类构建为具有宽泛范围或狭窄范围的一系列问题的一部分,其中包括是否注入依赖项以及是否具有全局可用的对象。目前有一种不幸的趋势,认为缩小范围总是好的。但是,在这种情况下,总是在封装和灵活性之间进行权衡。


1

我发现最好传递尽可能少的参数和尽可能多的参数。这使测试更加容易,并且不需要创建整个对象。

在您的示例中,如果仅使用用户ID或用户名,那么这就是您应该传递的全部信息。如果此模式重复几次,并且实际的用户对象大得多,那么我的建议是为此创建一个较小的接口。它可能是

interface IIdentifieable
{
    Guid ID { get; }
}

要么

interface INameable
{
    string Name { get; }
}

这使得使用模拟进行测试变得容易得多,您可以立即知道真正使用了哪些值。否则,尽管最后只需要一个或两个属性,但是您通常需要初始化具有许多其他依赖性的复杂对象。


1

这是我不时遇到的东西:

  • 方法采用类型User(或Product其他类型)的参数,该参数具有很多属性,即使该方法仅使用其中的少数几个。
  • 由于某种原因,即使没有完全填充的User对象,代码的某些部分也需要调用该方法。它创建一个实例并仅初始化该方法实际需要的属性。
  • 这发生了很多次。
  • 过了一会儿,当您遇到一个带有User参数的方法时,您发现自己必须找到对该方法的调用才能找到其User来源,以便知道填充了哪些属性。它是具有电子邮件地址的“真实”用户,还是只是为了传递用户ID和某些权限而创建的?

如果创建一个User并且仅填充一些属性(因为这些属性是方法所需的属性),则调用者实际上比应了解的更多有关方法的内部工作原理。

更糟糕的是,当您拥有的实例时User,您必须知道它的来源,以便知道填充了哪些属性。您不想知道这一点。

随着时间的流逝,当开发人员看到 User用作方法参数的容器时,他们可能会开始为一次性场景添加属性。现在,它变得越来越丑陋,因为该类变得杂乱无章,几乎总是null或默认属性。

这样的破坏不是不可避免的,但是当我们传递一个对象时,它反复发生,只是因为我们需要访问它的一些属性。危险区域是您第一次看到有人创建的实例User并仅填充一些属性,以便他们可以将其传递给方法。踩到它,因为这是一条黑暗的道路。

在可能的情况下,仅通过传递需要传递的内容,为下一个开发人员树立正确的榜样。

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.