永远不要让公众成员虚拟/抽象-是吗?


20

早在2000年代,我的一位同事就告诉我,将公共方法虚拟化或抽象化是一种反模式。

例如,他认为这样的课程设计得不好:

public abstract class PublicAbstractOrVirtual
{
  public abstract void Method1(string argument);

  public virtual void Method2(string argument)
  {
    if (argument == null) throw new ArgumentNullException(nameof(argument));
    // default implementation
  }
}

他说

  • 实现Method1并重写的派生类的开发人员Method2必须重复参数验证。
  • 如果基类的开发人员决定在Method1Method2以后的可自定义部分周围添加一些内容,他将无法执行。

相反,我的同事提出了这种方法:

public abstract class ProtectedAbstractOrVirtual
{
  public void Method1(string argument)
  {
    if (argument == null) throw new ArgumentNullException(nameof(argument));
    this.Method1Core(argument);
  }

  public void Method2(string argument)
  {
    if (argument == null) throw new ArgumentNullException(nameof(argument));
    this.Method2Core(argument);
  }

  protected abstract void Method1Core(string argument);

  protected virtual void Method2Core(string argument)
  {
    // default implementation
  }
}

他告诉我,将公共方法(或属性)虚拟化或抽象化与使字段公开一样糟糕。通过将字段包装到属性中,可以在以后需要时拦截对该字段的任何访问。这同样适用于公共虚拟/抽象成员:以ProtectedAbstractOrVirtual类中所示的方式包装它们,使基类开发人员可以拦截对虚拟/抽象方法的所有调用。

但是我不认为这是设计准则。甚至Microsoft也没有遵循它:只需看一看Stream该类即可验证这一点。

您如何看待该指导方针?这有意义吗,还是您认为API过于复杂?


5
制作方法virtual 允许可选的覆盖。 您的方法可能应该是公共的,因为它可能不会被覆盖。制作方法abstract 迫使您覆盖它们。它们可能应该是protected,因为它们在public上下文中不是特别有用。
罗伯特·哈维

4
实际上,protected当您要将抽象类的私有成员公开给派生类时,这是最有用的。无论如何,我并不特别关注您朋友的意见;选择最适合您的特定情况的访问修饰符。
罗伯特·哈维

4
您的同事提倡使用“ 模板方法模式”。两种方法都有用例,这取决于两种方法之间的依赖程度。
格雷格·伯格哈特

8
@GregBurghardt:听起来好像OP的同事建议始终使用模板方法模式,无论是否需要它。那是典型的模式过度使用-如果有人用锤子,迟早每个问题都会看起来像钉子;-)
Doc Brown

3
@PeterPerot:从没有公共字段的简单DTO开始,我从来没有问题,当这种DTO要求具有业务逻辑的成员时,就可以将它们重构为具有属性的类。当一个人作为图书馆供应商并且必须注意不要更改公共API时,情况肯定会有所不同,那么即使将公共领域变成同名的公共财产也会引起问题。
布朗

Answers:


30

由于要实现Method1并重写Method2的派生类的开发人员必须重复参数验证,因此使公共方法虚拟或抽象是一种反模式

正在混淆因果关系。它假定每个可重写方法都需要不可自定义的参数验证。但这恰恰相反:

如果要设计一种方法,以便在类的所有派生类中提供一些固定的参数验证(或更笼统地说,是可自定义和不可自定义的部分),使入口点成为非虚拟是有意义的,而是为内部可自定义的部分提供虚拟或抽象方法。

但是,有很多示例都拥有一个公共的虚拟方法是很有意义的,因为没有固定的,不可定制的部分:看看像ToStringor EqualsGetHashCode-这样的标准方法是否可以改善object这些类的设计,使它们不公开,并且同时虚拟?我不这么认为。

或者,就您自己的代码而言:当基类中的代码最终有意地看起来像这样时

 public void Method1(string argument)
 {
    // nothing to validate here, all strings including null allowed
    this.Method1Core(argument);
 }

之间具有这种分离Method1Method1Core仅没有明显的原因复杂的事情。


1
对于这种ToString()方法,Microsoft最好使其成为非虚拟方法,并引入虚拟模板方法ToStringCore()。原因:因此:ToString()-注意继承者。他们声明ToString()不应返回null。他们本可以通过实施来强制要求ToString() => ToStringCore() ?? string.Empty
彼得·佩罗

9
@PeterPerot:您链接到的该指南还建议不要返回string.Empty,您注意到了吗?并且通过引入类似ToStringCore方法的方法,推荐了许多其他无法在代码中强制执行的内容。因此,此技术可能不是正确的工具ToString
布朗

3
@Theraot:当然可以找到一些原因或理由来解释为什么ToString和Equals或GetHashcode可以采用不同的设计,但是今天它们保持原样(至少我认为它们的设计足以作为一个很好的例子)。
布朗

1
@PeterPerot“我看到了很多类似的代码,anyObjectIGot.ToString()而不是anyObjectIGot?.ToString()”-这有什么关系?您的ToStringCore()方法将防止返回空字符串,但是NullReferenceException如果对象为空,它仍将抛出a 。
IMil

1
@PeterPerot我不是想传达权威的观点。Microsoft使用的不多public virtual,但在某些情况下public virtual还可以。我们可以争辩一个空的不可定制的部分,以使代码成为未来的证明。。。但这是行不通的。返回并对其进行更改可能会破坏派生类型。因此,它一无所获。
Theraot

6

按照您的同事的建议进行操作确实为基类的实现者提供了更大的灵活性。但是随之而来的还有更多的复杂性,通常无法通过假定的收益来证明。

记住,对于基类的实施者增加灵活性是以牺牲较少的灵活性,以压倒一切的聚会。他们得到一些他们可能不会特别在意的强加行为。对他们来说,事情变得更加僵化。这是合理且有用的,但这全都取决于场景。

实现此命名约定(我知道)是为公共接口保留好名字,并在内部方法的名称前加上“ Do”。

一种有用的情况是,当执行的动作需要一些设置和一些关闭时。就像打开流并在重写器完成后将其关闭。通常,相同类型的初始化和完成。它是一种有效的使用模式,但强制在所有抽象和虚拟场景中使用它毫无意义。


1
待办事项方法前缀是一个选项。Microsoft经常使用Core方法后缀。
彼得·佩罗

@彼得·佩罗(Peter Perot)。我从未在任何Microsoft资料中看到Core前缀,但这可能是因为我最近没有引起太多关注。我怀疑他们最近开始这样做只是为了推广Core名称,为.NET Core取名。
马丁·马特

不,这是一顶旧帽子:BindingList。另外,我在某处找到了建议,可能是他们的框架设计指南或类似的东西。并且:这是一个后缀。;-)
Peter Perot

关键是派生类的灵活性较低。基类是抽象边界。基类告诉消费者其公共API的功能,并且定义了实现这些目标所需的API。如果派生类可以覆盖基类中的公共方法,则冒着违反Liskov替代原理的风险将增加。
Adrian McCarthy

2

在C ++中,这称为非虚拟接口模式(NVI)。(从前,它被称为模板方法。这很令人困惑,但是一些较早的文章使用了这种术语。)NVI由Herb Sutter提倡,他至少撰写了几次。我认为最早的是这里

如果我没有记错,前提是派生类不应该改变什么基类做,但怎么它做它。

例如,Shape可能具有Move方法来重新定位形状。一个具体的实现(例如,正方形和圆形)不应直接覆盖Move,因为Shape定义了Moving的含义(在概念上)。就内部位置的表示方式而言,Square的实现细节可能与Circle有所不同,因此它们将不得不重写某些方法来提供Move功能。

在简单的示例中,这通常可以归结为一个公共Move,该Move仅将所有工作委派给私有虚拟ReallyDoTheMove,因此似乎有很多开销,却没有任何好处。

但是,这不是一对一的对应关系。例如,您可以在Shape的公共API中添加一个Animate方法,并且可以通过循环调用ReallyDoTheMove来实现该方法。您最终得到两个都依赖于一个私有抽象方法的公共非虚拟方法API。您的圆和正方形不需要做任何额外的工作,也不需要重写Animate

基类定义了使用者使用的公共接口,并且定义了实现那些公共方法所需的原始操作的接口。派生类型负责提供这些原始操作的实现。

我不知道C#和C ++之间的任何差异都会改变类设计的这一方面。


好发现!现在我记得我发现恰好是在2000年代发布了您的第二个链接指向(或它的一个副本)。我记得我当时在寻找有关同事主张的更多证据,并且在C#上下文中找不到任何东西,但在C ++中找不到任何东西。这个。是的。它!:-)但是,回到C#领域之后,似乎很少使用这种模式。也许人们意识到,以后添加基本功能也会破坏派生类,并且严格使用TMP或NVIP代替公共虚拟方法并不总是有意义。
彼得·佩罗

自我说明:很高兴知道此模式有一个名称:NVIP。
彼得·佩罗
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.