对象的功能是否应仅由其实现的接口来标识?


36

C#的is运算符和Java的instanceof运算符允许您在已实现对象实例的接口(或更不幸的是,其基类)上分支。

根据接口提供的功能,将此功能用于高层分支是否合适?

还是基类应该提供布尔变量以提供一个接口来描述对象具有的功能?

例:

if (account is IResetsPassword)
       ((IResetsPassword)account).ResetPassword();
else
       Print("Not allowed to reset password with this account type!");

if (account.CanResetPassword)
       ((IResetsPassword)account).ResetPassword();
else
       Print("Not allowed to reset password with this account type!");

使用接口实现进行功能识别是否有陷阱?


这个例子就是一个例子。我想知道一个更通用的应用程序。


10
看一下您的两个代码示例。哪一个更具可读性?
罗伯特·哈维

39
只是我的意见,但我会选择第一个,只是因为它可以确保您可以安全地转换为适当的类型。第二个假设CanResetPassword仅在某些东西实现时才是正确的IResetsPassword。现在,您要使一个属性确定类型系统应控制的内容(最终将在执行强制转换时确定)。
Becuzz

10
@nocomprende:xkcd.com/927
罗伯特·哈维

14
您的问题标题和问题正文提出了不同的问题。回答标题:理想的是。解决正文的问题:如果您发现自己正在检查字体并手动进行铸造,则可能是您没有正确利用字体系统。您能更具体地告诉我们您在这种情况下如何找到自己吗?
MetaFight

9
这个问题看似简单,但是当您开始思考时,问题会变得越来越广泛。我想到的问题是:您是否希望向现有帐户添加新行为或创建具有不同行为的帐户类型?所有帐户类型的行为是否相似,还是两者之间有很大差异?示例代码是否了解帐户的所有类型,或者仅了解基本的IAccount接口?
欣快感,

Answers:


7

请注意,如果可能的话,最好避免向下转换,因此出于以下两个原因,首选第一种形式:

  1. 您让类型系统为您工作。在第一个示例中,如果is发生火灾,那么向下转换肯定会起作用。在第二种形式中,您需要手动确保只有实现的对象才IResetsPassword对该属性返回true
  2. 脆弱的基层。您需要为要添加的每个接口的基类/接口添加属性。这是麻烦且容易出错的。

这并不是说您无法在某种程度上改善这些问题。例如,您可以让基类具有一组已发布的接口,您可以检查这些接口是否包含在其中。但是实际上,您实际上只是手动实现了现有类型系统的一部分,这是多余的。

顺便说一句,我的天生偏好是组成而不是继承,但主要是关于国家。通常,接口继承并不那么糟糕。另外,如果发现自己实现了一个穷人的类型层次结构,那么最好使用已经存在的类型层次结构,因为编译器将为您提供帮助。


我也非常喜欢复合打字。+1是让我看到此特定示例未正确利用它。虽然,我会说,当功能变化很大时(与实现方式相反),计算类型化(如传统继承)受到更多限制。因此,接口方法解决了与合成类型输入或标准继承不同的问题。
TheCatWhisperer

您可以像这样简化检查:(account as IResettable)?.ResetPassword();var resettable = account as IResettable; if(resettable != null) {resettable.ResetPassword()} else {....}
Berin Loritsch

89

对象的功能是否应仅由其实现的接口来标识?

根本不应该确定对象功能。

使用对象的客户端不需要了解其工作原理。客户应该只知道它可以告诉对象要做的事情。告知对象后,对象的工作不是客户的问题。

所以,而不是

if (account is IResetsPassword)
    ((IResetsPassword)account).ResetPassword();
else
    Print("Not allowed to reset password with this account type!");

要么

if (account.CanResetPassword)
    ((IResetsPassword)account).ResetPassword();
else
    Print("Not allowed to reset password with this account type!");

考虑

account.ResetPassword();

要么

account.ResetPassword(authority);

因为account已经知道这是否可行。为什么要问呢?只需说出您想要的内容,然后让它做任何事情即可。

想象一下,这样做的方式使客户不在乎它是否起作用,因为那是另外一个问题。客户的工作只是尝试。现在已经完成,还有其他事情要处理。这种样式有很多名字,但是我最喜欢的名字是告诉,不要问

给客户写信时,以为您必须跟踪所有事情,并向您拉动您认为需要知道的一切,这非常诱人。当您这样做时,您会将对象内翻。珍惜您的无知。将细节推开,让物体处理它们。知道的越少越好。


54
多态性仅在我不知道或不在乎我在说什么时才起作用。因此,我处理这种情况的方法是仔细避免这种情况。
candied_orange

7
如果客户端确实确实需要知道尝试是否成功,则该方法可以返回布尔值。if (!account.AttemptPasswordReset()) /* attempt failed */;这样,客户可以知道结果,但可以避免问题中决策逻辑的某些问题。如果尝试是异步的,则将传递一个回调。
Radiodef

5
@DavidZ我们都说英语。因此,我可以告诉您两次评论此评论。这是否意味着您有能力做到这一点?在告诉您要这样做之前是否需要知道是否可以?
candied_orange

5
@QPaysTaxes为您所知account,在构造错误处理程序时将其传递给它。此时可能会出现弹出窗口,日志记录,打印输出和音频警报。客户的工作就是说“现在就做”。它并不需要做更多的事情。您不必在一处做所有事情。
candied_orange

6
@QPaysTaxes可以在其他地方以多种不同的方式进行处理,这对于本次对话是多余的,只会使水变得混乱。
Tom.Bowen89

17

背景

继承是一个强大的工具,可在面向对象的编程中发挥作用。但是,它不能很好地解决每个问题:有时,其他解决方案会更好。

如果您回想起早期的计算机科学课程(假设您拥有CS学位),您可能还记得一位教授在给您一段文字,说明客户希望软件做什么。您的工作是阅读该段落,确定参与者和动作,并给出有关类和方法的粗略概述。那里会有一些流浪汉,看起来很重要,但不是很重要。也很可能会误解需求。

这是一项重要的技能,即使我们中最有经验的人也会犯错:正确识别需求并将其转换为机器语言。

你的问题

根据您所写的内容,我认为您可能会误解类可以执行的可选操作的一般情况。是的,我知道您的代码只是一个示例,您对一般情况感兴趣。但是,听起来您想知道如何处理对象的某些子类型可以执行操作但其他子类型不能执行操作的情况。

仅仅因为诸如之类的对象account具有帐户类型,并不意味着可以转换为OO语言的类型。人类语言中的“类型”并不总是意味着“类别”。在帐户的上下文中,“类型”可能与“权限集”更紧密相关。您想要使用用户帐户执行某项操作,但是该帐户可能无法执行该操作。与其使用继承,不如使用委托或安全令牌。

我的解决方案

考虑一个帐户类,它可以执行几个可选操作。为什么不让帐户返回一个委托对象(密码重置器,表单提交器等)或访问令牌,而不是通过继承定义“可以执行操作X”?

account.getPasswordResetter().doAction();
account.getFormSubmitter().doAction(view.getContents());

AccountManager.resetPassword(account, account.getAccessToken());

最后一个选项的好处是,如果我想使用我的帐户凭据来重置别人的密码,该怎么办?

AccountManager.resetPassword(otherAccount, adminAccount.getAccessToken());

系统不仅更加灵活,而且不仅删除了类型转换,而且设计也更具表现力。我可以阅读并轻松了解它在做什么以及如何使用。


TL; DR:这看起来像是XY问题。通常,当遇到两个次优的选项时,值得退后一步来思考:“我在这里真正想完成什么?我应该真正在考虑如何使类型转换不那么丑陋,还是应该寻找方法来解决?完全删除打字稿?”


1
设计气味+1,即使您没有使用该短语也是如此。
贾里德·史密斯

如果重置密码根据类型具有完全不同的参数,该怎么办?ResetPassword(pswd)与ResetPasswordToRandom()
TheCatWhisperer

1
@TheCatWhisperer第一个示例是“更改密码”,这是一个不同的操作。第二个例子是我在这个答案中描述的。

为什么不呢account.getPasswordResetter().resetPassword();
AnoE

1
The benefit to the last option there is what if I want to use my account credentials to reset someone else's password?.. 简单。您为另一个帐户创建一个Account域对象,然后使用该对象。静态类对于单元测试是有问题的(根据定义,该方法将需要访问一些存储,因此现在您不能再轻易地对任何与该类相关的代码进行单元测试),最好避免使用。返回一些其他接口的选项通常是一个好主意,但是并不能真正解决潜在的问题(您只是将问题移至另一个接口)。
Voo

1

Bill Venner 说您的方法绝对可以 ; 跳至标题为“何时使用instanceof”的部分。您将向下转换到仅实现该特定行为的特定类型/接口,因此绝对有必要对此进行选择。

但是,如果您想使用另一种方法,可以用很多方法来减少a牛。

有一种多态方法。您可能会争辩说所有帐户都有密码,因此所有帐户至少应能够尝试重设密码。这意味着,在基本帐户类中,我们应该有一个resetPassword()所有帐户都可以实现的方法,但必须以自己的方式实现。问题是当能力不存在时该方法应如何表现。

无论是否重置密码,它都可能返回一个空白,并在无提示的情况下完成,如果不重置密码,则可能负责内部打印消息。不是最好的主意。

它可以返回一个布尔值,指示重置是否成功。打开该布尔值,我们可以认为密码重置失败。

它可能会返回一个字符串,指示密码重置尝试的结果。该字符串可以提供有关重置失败原因以及可以输出的更多详细信息。

它可以返回一个ResetResult对象,该对象传达更多详细信息并合并所有先前的返回元素。

如果您尝试重置不具备此功能的帐户,它可能会返回一个空值,并引发异常(不要这样做,因为出于各种原因,将异常处理用于常规流控制是不明智的做法)。

拥有一个匹配canResetPassword()方法似乎并不是世界上最糟糕的事情,因为从概念上讲,它是类内置的静态功能。但是,这说明了为什么方法方法不是一个好主意,因为它暗示了该功能是动态的并且canResetPassword()可能会发生变化,这也增加了在请求许可和进行调用之间可能发生变化的可能性。如其他地方所述,说而不是征求许可。

可以选择在继承之上进行组合:您可以有一个final passwordResetter字段(或等效的getter)和等效的类,可以在调用前检查是否为null。虽然它的行为有点像请求许可,但是确定性可以避免任何推断出的动态特性。

您可能会考虑将功能外部化到其自己的类中,该类可以将一个帐户作为参数并对其进行操作(例如resetter.reset(account)),尽管这通常也是一种不好的做法(通常的类比是店主伸入您的钱包以获取现金)。

在具有该功能的语言中,您可能会使用mixin或traits,但是最终会从开始的位置开始检查这些功能的存在。


并非所有帐户都可能具有密码(无论如何从该特定系统的角度来看)。例如,帐户我是第三方... google,facebook等。并不是说这不是一个好答案!
TheCatWhisperer

0

对于“ 可以重设密码 ”的此特定示例,我建议使用继承而非继承(在这种情况下,接口/合同的继承)。因为,通过这样做:

class Foo : IResetsPassword {
    //...
}

您将立即(在编译时)指定您的类“ 可以重置密码 ”。但是,如果在您的场景中,功能的存在是有条件的并且取决于其他因素,那么您就无法在编译时指定事物。然后,我建议这样做:

class Foo {

    PasswordResetter passwordResetter;

}

现在,在运行时,您可以myFoo.passwordResetter != null在执行此操作之前检查是否。如果您想进一步分离事物(并且计划添加更多功能),则可以:

class Foo {
    //... foo stuff
}

class PasswordResetOperation {
    bool Execute(Foo foo) { ... }
}

class SendMailOperation {
    bool Execute(Foo foo) { ... }
}

//...and you follow this pattern for each new capability...

更新

在阅读了OP的其他答案和评论后,我了解到问题不在于组成解决方案。因此,我认为问题在于如何在如下情况下更好地识别对象的功能:

class BaseAccount {
    //...
}
class GuestAccount : BaseAccount {
    //...
}
class UserAccount : BaseAccount, IMyPasswordReset, IEditPosts {
    //...
}
class AdminAccount : BaseAccount, IPasswordReset, IEditPosts, ISendMail {
    //...
}

//Capabilities

interface IMyPasswordReset {
    bool ResetPassword();
}

interface IPasswordReset {
    bool ResetPassword(UserAccount userAcc);
}

interface IEditPosts {
    bool EditPost(long postId, ...);
}

interface ISendMail {
    bool SendMail(string from, string to, ...);
}

现在,我将尝试分析提到的所有选项:

OP第二个例子:

if (account.CanResetPassword)
       ((IResetsPassword)account).ResetPassword();
else
       Print("Not allowed to reset password with this account type!");

假设这段代码正在接收一些基本帐户类(例如:BaseAccount在我的示例中);这是不好的,因为它在基类中插入了布尔值,并用根本没有意义的代码污染了布尔值。

OP第一个例子:

if (account is IResetsPassword)
       ((IResetsPassword)account).ResetPassword();
else
       Print("Not allowed to reset password with this account type!");

为了回答这个问题,这比上一个选项更合适,但是根据实现的不同,它会破坏L的solid原理,并且可能像这样的检查会在代码中传播,并使进一步的维护更加困难。

CandiedOrange的分析服务:

account.ResetPassword(authority);

如果将此ResetPassword方法插入到BaseAccount类中,那么它还会使用不适当的代码污染基类,如OP的第二个示例

雪人的答案:

AccountManager.resetPassword(otherAccount, adminAccount.getAccessToken());

这是一个很好的解决方案,但是它认为功能是动态的(并且可能会随时间变化)。但是,在阅读了OP的几条评论之后,我想这里的话题是关于多态性和静态定义的类(尽管帐户的示例直观地指向了动态场景)。EG:在此AccountManager示例中,对权限的检查将是对DB的查询;在OP问题中,检查是尝试投射对象。

我的另一个建议:

使用模板方法模式进行高级分支。所提到的类层次结构保持不变。我们仅为对象创建更合适的处理程序,以避免强制类型转换和不适当的属性/方法污染基类。

//Template method
class BaseAccountOperation {

    BaseAccount account;

    void Execute() {

        //... some processing

        TryResetPassword();

        //... some processing

        TrySendMail();

        //... some processing
    }

    void TryResetPassword() {
        Print("Not allowed to reset password with this account type!");
    }

    void TrySendMail() {
        Print("Not allowed to reset password with this account type!");
    }
}

class UserAccountOperation : BaseAccountOperation {

    UserAccount userAccount;

    void TryResetPassword() {
        account.ResetPassword(...);
    }

}

class AdminAccountOperation : BaseAccountOperation {

    AdminAccount adminAccount;

    override void TryResetPassword() {
        account.ResetPassword(...);
    }

    void TrySendMail() {
        account.SendMail(...);
    }
}

您可以使用字典/哈希表将操作绑定到适当的帐户类,或者使用扩展方法,使用dynamic关键字执行运行时操作,或者作为最后一个选项,使用一个强制转换,以将帐户对象传递给操作(在在这种情况下,操作开始时的转换次数仅为1)。


1
仅始终实现“ Execute()”方法但有时却什么都不做会起作用吗?它返回一个布尔值,所以我猜这意味着它是否实现了。这使它返回给客户端:“我试图重设密码,但是(由于任何原因)没有发生,所以现在我应该...”

4
具有“ canX()”和“ doX()”方法是一种反模式:如果接口公开了“ doX()”方法,则即使执行X表示无操作(例如,空对象模式),它也能够执行X )。它应该永远是对的接口的用户有义务从事时间耦合(invoke方法的方法b)之前,因为这是太容易得到错误的,破东西。

1
具有接口与使用组合而不继承有关。它们仅代表可用的功能。该功能如何发挥作用与接口的使用无关。您在这里所做的是使用接口的临时实现,而没有任何好处。
宫本晃

有趣:投票最多的答案基本上说出了我上面的评论。有时候你会很幸运。

@nocomprende-是的,我根据一些评论更新了我的答案。感谢您的反馈:)
Emerson Cardoso

0

您提供的两个选项都不是好的OO。如果您正在编写围绕对象类型的if语句,则很可能是错误的OO(有例外,这不是一个例外。)这是您问题的简单OO答案(可能不是有效的C#):

interface IAccount {
  bool CanResetPassword();

  void ResetPassword();

  // Other Account operations as needed
}

public class Resetable : IAccount {
  public bool CanResetPassword() {
    return true;
  }

  public void ResetPassword() {
    /* RESET PASSWORD */
  }
}

public class NotResetable : IAccount {
  public bool CanResetPassword() {
    return false;
  }

  public void ResetPassword() {
    Print("Not allowed to reset password with this account type!");}
  }

我已经修改了此示例,以匹配原始代码在做什么。根据一些评论,似乎人们对这里是否是“正确的”特定代码感到困惑。那不是这个例子的重点。整个多态重载本质上是根据对象的类型有条件地执行逻辑的不同实现。在这两个示例中,您所做的都是手工干扰您的语言为您提供的功能。简而言之,您可以摆脱子类型,并将重置功能设置为Account类型的布尔属性(忽略子类型的其他功能)。

如果没有更广阔的设计视野,就无法判断这是否是您特定系统的良好解决方案。这很简单,如果它可以满足您的工作需求,则除非您在调用ResetPassword()之前未能检查CanResetPassword(),否则您可能无需再考虑太多。您还可以返回布尔值或以静默方式失败(不推荐)。这实际上取决于设计的细节。


并非所有帐户都可以重置。此外,帐户是否可能具有比可重置功能更多的功能?
TheCatWhisperer

2
“并非所有帐户都可以重置。” 不知道这是在编辑之前写的。第二类是NotResetable。我期望这样:“一个帐户可能具有比可重置功能更多的功能”,但这似乎对手头的问题并不重要。
JimmyJames

1
我认为,这是朝正确方向发展的解决方案,因为它为OO内部提供了一个不错的解决方案。可能会将接口名称更改为IPasswordReseter(或类似的名称),以分隔接口。IAccount可以声明为支持多个其他接口。这将使您可以选择具有实现的类,然后由委派对域对象进行组合和使用。@JimmyJames,较小的nitpick,在C#上,两个类都需要指定它们实现IAccount。否则,代码看起来有些奇怪;-)
宫本晃

1
另外,请在其他有关CanX()和DoX()的答案之一中,查看Snowman的评论。但是,并非总是反模式,因为它使用了反模式(例如关于UI行为)
Miyamoto Akira

1
这个答案很好地开始:这是糟糕的OOP。不幸的是,您的解决方案同样糟糕,因为它还会颠覆类型系统,但会引发异常。一个好的解决方案看起来与众不同:类型上没有未实现的方法。.NET框架实际上对某些类型使用您的方法,但这无疑是一个错误。
康拉德·鲁道夫

0

回答

对于您的问题,我的看法有些slightly昧:

对象的功能应该通过自身而不是通过实现接口来标识。但是,静态(强类型)语言将限制这种可能性(通过设计)。

附录:

分支对象类型是邪恶的。

漫步

我看到您没有添加c#标签,而是在一般object-oriented情况下询问。

您所描述的是静态/强类型语言的技术性,而不是专门针对“ OO”的。您的问题尤其出在您无法在编译时具有足够狭窄的类型的情况下(通过变量定义,方法参数/返回值定义或显式强制转换)调用这些语言的方法。过去,我已经用两种语言完成了您的两种变体,这些天对我来说似乎都很丑陋。

在动态类型的OO语言中,由于称为“鸭子类型”的概念不会发生此问题。简而言之,这意味着您基本上不会在任何地方声明对象的类型(创建对象时除外)。您将消息发送到对象,对象将处理这些消息。您不必担心该对象是否实际上具有该名称的方法。某些语言甚至具有通用的包罗万象的method_missing方法,这使其更加灵活。

因此,以这种语言(例如Ruby),您的代码将变为:

account.reset_password

期。

account要么重设密码,抛出一个“拒绝”的异常,或抛出一个“不知道该如何处理‘reset_password’”的例外。

如果出于任何原因需要显式分支,则仍然可以使用功能,而不是使用类:

account.reset_password  if account.can? :reset_password

(这里can?只是一个方法,它检查对象是否可以执行某些方法而无需调用它。)

请注意,尽管我目前的个人偏好是动态类型的语言,但这只是个人偏好。静态类型语言也有其用处,所以请不要将这种混乱当作对静态类型语言的猛烈抨击...


很高兴在这里发表您的看法!我们在Java / C#方面倾向于忘记存在OOP的另一个分支。当我告诉我的同事时,JS(针对所有问题)在许多方面比C#更具面向对象,他们嘲笑我!
TheCatWhisperer

我喜欢C#,并且我认为它是一种很好的通用语言,但是我个人认为,就面向对象而言,它是很差的。在功能和实践上,它都倾向于鼓励程序编程。
TheCatWhisperer

2
可以说,用鸭子类型语言测试方法的存在与用静态类型语言测试接口的实现是相同的:您正在运行时检查对象是否具有特定功能。每个方法声明实际上都是其自己的接口,仅由方法名称标识,并且其存在不能用于推断有关该对象的任何其他信息。
IMSoP

0

您的选择似乎来自不同的动力。第一个似乎是由于不良对象建模而雇用的黑客。它表明您的抽象是错误的,因为它们破坏了多态性。它很有可能破坏“开放闭合”原理,从而导致更紧密的耦合。

从OOP角度来看,您的第二个选择可能很好,但是类型转换使我感到困惑。如果您的帐户允许您重设密码,那为什么还要进行类型转换?因此,假设您的CanResetPassword条款代表了作为帐户抽象一部分的一些基本业务需求,那么您的第二个选择就是确定。

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.