为什么“最终”关键字会有用?


54

似乎Java拥有声明类不可继承的能力,而现在C ++也拥有。但是,根据SOLID中的“打开/关闭”原理,为什么这样做有用?对我来说,final关键字听起来像friend-是合法的,但是如果您使用它,则很可能是设计错误。请提供一些示例,其中不可派生的类将成为优秀的体系结构或设计模式的一部分。


43
您为什么认为如果对类进行了错误的设计final?许多人(包括我在内)发现,使每个非抽象类都成为一个很好的设计final
斑点

20
偏爱继承而不是继承,您可以拥有每个非抽象类final
安迪

24
从某种意义上说,打开/关闭原则在20世纪是不合时宜的,当时的口头禅是对继承自其他类的类继承一个类的层次结构。这对于讲授面向对象的编程非常有用,但事实证明,将其应用于实际问题时会造成纠结,无法维护的混乱。设计一个可扩展的类很难。
大卫·哈门

34
@DavidArno不要荒谬。继承是面向对象程序设计的必要条件,它远不像某些过于教条的人喜欢讲的那样复杂或混乱。它是一个工具,就像其他工具一样,一个好的程序员知道如何使用正确的工具来完成工作。
梅森惠勒

48
作为一个正在恢复的开发人员,我似乎还记得final是一个出色的工具,可以防止有害行为进入关键部分。我似乎还记得,继承在各种方面都是一种强大的工具。几乎就像喘不过气来,不同的工具各有利弊,我们工程师在生产软件时必须权衡这些因素!
corsiKa

Answers:


136

final 表达意向。它告诉用户类,方法或变量“不应更改此元素,并且如果要更改它,则说明您不了解现有设计。”

这很重要,因为如果必须预料到您编写的每个类和方法都可能会被子类更改为完全不同的操作,则程序体系结构将非常非常困难。最好预先决定哪些元素应该是可更改的,哪些元素是不可更改的,并通过强制执行不可更改性final

您也可以通过注释和体系结构文档来做到这一点,但是总比让希望将来的用户阅读并遵守文档更好。


14
我也是。但是,曾经编写广泛复用的基类(例如框架或媒体库)的任何人都比期望应用程序程序员以理智的方式行事更好。他们会以您甚至认为不可能的方式颠覆,滥用和扭曲您的发明,除非您用铁腕将其锁定。
凯莉安·佛斯

8
@KilianFoth好的,但是,老实说,您的问题是应用程序程序员怎么做的?
coredump

20
@coredump使用我的库的人严重创建了错误的系统。不良的系统会滋生不良的声誉。用户将无法从弗雷德·兰德(Fred Random)极其不稳定的应用程序中区分出Kilian的出色代码。结果:我在编程信誉和客户方面输了。使您的代码难以被滥用是一个问题。
凯莉安·佛斯

21
声明“不应更改此元素,如果要更改它,则说明您不了解现有设计。” 是我不可思议的傲慢,而不是我与之合作的任何图书馆所希望的态度。如果每次我只有一角钱,那么一个可笑的,过度封装的库就让我没有任何机制可以更改需要更改的内部状态,因为作者无法预料到重要的用例……
Mason Wheeler

31
@JimmyB经验法则:如果您认为知道自己的创作将用于什么用途,那么您已经错了。 贝尔认为电话实质上是一个Muzak系统。面巾纸的发明是为了更方便地卸妆。IBM总裁托马斯·沃森(Thomas Watson)曾说过:“我认为可能存在五台计算机的世界市场。” 代表吉姆·森森布伦纳(Jim Sensenbrenner)于2001年提出《爱国者法案》,他在记录中说,这是专门旨在防止国家安全局“通过“爱国者行为授权”行事。等等...
梅森·惠勒

59

它避免了脆弱的基类问题。每个类都带有一组隐式或显式保证和不变式。Liskov替代原则要求该类的所有子类型还必须提供所有这些保证。但是,如果不使用,这很容易违反final。例如,让我们有一个密码检查器:

public class PasswordChecker {
  public boolean passwordIsOk(String password) {
    return password == "s3cret";
  }
}

如果我们允许重写该类,则一个实现可以将所有人锁定,另一个实现可以使所有人访问:

public class OpenDoor extends PasswordChecker {
  public boolean passwordIsOk(String password) {
    return true;
  }
}

这通常不行,因为子类现在的行为与原始行为非常不兼容。如果我们确实打算将班级与其他行为一起扩展,那么责任链会更好:

PasswordChecker passwordChecker =
  new DefaultPasswordChecker(null);
// or:
PasswordChecker passwordChecker =
  new OpenDoor(null);
// or:
PasswordChecker passwordChecker =
 new DefaultPasswordChecker(
   new OpenDoor(null)
 );

public interface PasswordChecker {
  boolean passwordIsOk(String password);
}

public final class DefaultPasswordChecker implements PasswordChecker {
  private PasswordChecker next;

  public DefaultPasswordChecker(PasswordChecker next) {
    this.next = next;
  }

  @Override
  public boolean passwordIsOk(String password) {
    if ("s3cret".equals(password)) return true;
    if (next != null) return next.passwordIsOk(password);
    return false;
  }
}

public final class OpenDoor implements PasswordChecker {
  private PasswordChecker next;

  public OpenDoor(PasswordChecker next) {
    this.next = next;
  }

  @Override
  public boolean passwordIsOk(String password) {
    return true;
  }
}

当更多复杂的类调用其自己的方法时,问题将变得更加明显,并且这些方法可以被覆盖。在漂亮地打印数据结构或编写HTML时,有时会遇到这种情况。每个方法负责一些小部件。

public class Page {
  ...;

  @Override
  public String toString() {
    PrintWriter out = ...;
    out.print("<!DOCTYPE html>");
    out.print("<html>");

    out.print("<head>");
    out.print("</head>");

    out.print("<body>");
    writeHeader(out);
    writeMainContent(out);
    writeMainFooter(out);
    out.print("</body>");

    out.print("</html>");
    ...
  }

  void writeMainContent(PrintWriter out) {
    out.print("<div class='article'>");
    out.print(htmlEscapedContent);
    out.print("</div>");
  }

  ...
}

现在,我创建一个子类,添加更多样式:

class SpiffyPage extends Page {
  ...;


  @Override
  void writeMainContent(PrintWriter out) {
    out.print("<div class='row'>");

    out.print("<div class='col-md-8'>");
    super.writeMainContent(out);
    out.print("</div>");

    out.print("<div class='col-md-4'>");
    out.print("<h4>About the Author</h4>");
    out.print(htmlEscapedAuthorInfo);
    out.print("</div>");

    out.print("</div>");
  }
}

现在暂时忽略这不是生成HTML页面的好方法,如果我想再次更改布局会怎样?我必须创建一个SpiffyPage以某种方式包装该内容的子类。我们在这里看到的是模板方法模式的偶然应用。模板方法是基类中定义明确的扩展点,将被覆盖。

如果基类发生变化会怎样?如果HTML内容更改太多,则可能会破坏子类提供的布局。因此,事后更改基类并不真正安全。如果您所有的类都在同一个项目中,则这并不明显,但是如果基类是其他人所构建的某些已发布软件的一部分,则这一点将非常明显。

如果打算采用这种扩展策略,我们可以允许用户交换每个零件的生成方式。无论哪种情况,都可以从外部提供每个块的策略。或者,我们可以嵌套装饰器。这将等同于上面的代码,但是更加明确和灵活得多:

Page page = ...;
page.decorateLayout(current -> new SpiffyPageDecorator(current));
print(page.toString());

public interface PageLayout {
  void writePage(PrintWriter out, PageLayout top);
  void writeMainContent(PrintWriter out, PageLayout top);
  ...
}

public final class Page {
  private PageLayout layout = new DefaultPageLayout();

  public void decorateLayout(Function<PageLayout, PageLayout> wrapper) {
    layout = wrapper.apply(layout);
  }

  ...
  @Override public String toString() {
    PrintWriter out = ...;
    layout.writePage(out, layout);
    ...
  }
}

public final class DefaultPageLayout implements PageLayout {
  @Override public void writeLayout(PrintWriter out, PageLayout top) {
    out.print("<!DOCTYPE html>");
    out.print("<html>");

    out.print("<head>");
    out.print("</head>");

    out.print("<body>");
    top.writeHeader(out, top);
    top.writeMainContent(out, top);
    top.writeMainFooter(out, top);
    out.print("</body>");

    out.print("</html>");
  }

  @Override public void writeMainContent(PrintWriter out, PageLayout top) {
    ... /* as above*/
  }
}

public final class SpiffyPageDecorator implements PageLayout {
  private PageLayout inner;

  public SpiffyPageDecorator(PageLayout inner) {
    this.inner = inner;
  }

  @Override
  void writePage(PrintWriter out, PageLayout top) {
    inner.writePage(out, top);
  }

  @Override
  void writeMainContent(PrintWriter out, PageLayout top) {
    ...
    inner.writeMainContent(out, top);
    ...
  }
}

(必须使用附加top参数来确保调用writeMainContent通过装饰器链的顶部。这模拟了称为open recursion的子类功能。)

如果我们有多个装饰器,我们现在可以更自由地混合它们。

重用现有类的某些部分的需求远比稍微修改现有功能的需求更多。我见过这样的情况:有人想要一堂课,您可以在其中添加项目并遍历所有项目。正确的解决方案是:

final class Thingies implements Iterable<Thing> {
  private ArrayList<Thing> thingList = new ArrayList<>();

  @Override public Iterator<Thing> iterator() {
    return thingList.iterator();
  }

  public void add(Thing thing) {
    thingList.add(thing);
  }

  ... // custom methods
}

相反,他们创建了一个子类:

class Thingies extends ArrayList<Thing> {
  ... // custom methods
}

这突然意味着的整个界面ArrayList已成为我们界面的一部分。用户可以remove()事物或get()特定索引处的事物。打算这样吗?好。但通常,我们不会仔细考虑所有后果。

因此建议

  • 从来extend没有认真思考过的课程。
  • 始终将您的类标记为,final除非您打算覆盖任何方法。
  • 在要交换实现的地方创建接口,例如用于单元测试。

有许多示例必须打破此“规则”,但它通常会指导您进行良好而灵活的设计,并避免由于基类的意外更改(或子类作为基类的实例的意外使用)而导致的错误。 )。

一些语言具有更严格的执行机制:

  • 默认情况下,所有方法都是最终方法,必须将其显式标记为 virtual
  • 它们提供的私有继承不继承接口,而仅继承实现。
  • 它们要求将基类方法标记为虚方法,并且还要求将所有替代标记为标记。这样可以避免出现以下问题:子类定义了新方法,但后来将具有相同签名的方法添加到基类中,但不希望将其用作虚拟方法。

3
提及“脆弱的基类问题”,您至少应得+100。:)
David Arno

6
我对这里提出的观点不服气。是的,脆弱的基类是一个问题,但是final并不能通过更改实现解决所有问题。您的第一个示例很糟糕,因为您假设您知道PasswordChecker的所有可能用例(“将所有人拒之门外或允许所有人访问……不行” –说谁?)。您最后的“因此建议...”列表确实很糟糕-您基本上是在主张不扩展任何内容并将所有内容标记为最终内容-从而完全消除了OOP,继承和代码重用的用处。
adelphus

4
您的第一个示例不是脆弱的基类问题的示例。在脆弱的基类问题中,对基类的更改会破坏子类。但是在该示例中,您的子类不遵循子类的约定。这是两个不同的问题。(此外,在某些情况下,您可以禁用密码检查器(例如开发)实际上是合理的)
Winston Ewert

5
“它避免了脆弱的基类问题”-就像杀死自己避免饿了一样。
user253751 '16

9
@immibis,它更像是避免进食,以避免食物受到伤害。当然,永不饮食将是一个问题。但是,仅在您信任的地方吃饭是很有意义的。
温斯顿·埃韦特

33

令我惊讶的是,没有人提到Joshua Bloch撰写的有效Java,第二版(至少每个Java开发人员都需要阅读该书)。书中的第17项对此进行了详细讨论,其标题为:“ 设计和记录继承文件,否则禁止继承 ”。

我不会在本书中重复所有好的建议,但是这些特殊的段落似乎很相关:

但是普通的具体课程呢?传统上,它们既不是最终的,也不是为子类设计和记录的,但是这种情况很危险。每次在此类中进行更改时,扩展该类的客户端类都有可能会中断。这不仅仅是一个理论问题。在修改非最终具体类的内部结构之后,通常会收到与子类相关的错误报告,该非最终具体类的设计和文档未为继承而设计和记录。

解决此问题的最佳方法是禁止在未设计和记录为可安全子类化的类中进行子类化。有两种禁止子类化的方法。两者中较容易的是将类声明为final。另一种方法是将所有构造函数设为私有或包私有,并添加公共静态工厂代替构造函数。在第15条中讨论了提供内部灵活使用子类的灵活性的另一种方法。


21

final有用的原因之一是,它确保您不能以违反父类合同的方式对类进行子类化。这样的子类将违反SOLID(最重要的是“ L”),并通过创建一个类来final阻止它。

一个典型的例子是不可能以使子类可变的方式来子类化不可变的类。在某些情况下,这种行为更改可能会导致非常令人惊讶的效果,例如,当您在地图中将某物用作键时,认为键是不可变的,而实际上您使用的是可变的子类。

在Java中,如果能够继承String并传递此类对象,则可以引入许多有趣的安全性问题(使它们可变)(或在有人调用其方法时使其回叫,从而有可能将敏感数据从系统中拉出)。围绕一些与类加载和安全性相关的内部代码。

在某些情况下,Final也有助于防止简单的错误,例如在方法中将相同的变量重复用于两件事等。在Scala中,鼓励您仅使用val与Java中的final变量大致对应的变量,而实际上不使用a var还是怀疑非最终变量。

最后,至少在理论上,编译器在知道某个类或方法是最终的时可以执行一些额外的优化,因为当您在最终类上调用某个方法时,您确切地知道将调用哪个方法,而不必执行通过虚拟方法表检查继承。


6
最后,至少在理论上,编译器可以 =>我亲自检查了Clang的去虚拟化过程,并确认它已在实践中使用。
Matthieu M.

但是,编译器不能预先告知没有人重写一个类或方法,无论其是否标记为final?
JesseTG

3
@JesseTG如果可能一次访问所有代码。但是,单独的文件编译又如何呢?
恢复莫妮卡

3
@JesseTG脱虚拟化或(单态/多态)内联缓存是JIT编译器中的一种常用技术,因为系统知道当前正在加载哪些类,并且如果没有覆盖方法的假设被证明是错误的,则可以对代码进行优化。但是,提前编译器不能。当我编译一个Java类和使用该类的代码时,以后可以编译一个子类并将实例传递给使用的代码。最简单的方法是在类路径的前面添加另一个jar。编译器无法了解所有这些信息,因为这是在运行时发生的。
阿蒙

5
@MTilsted您可以假定字符串是不可变的,因为可以通过禁止通过反射更改字符串SecurityManager。大多数程序不使用它,但它们也不运行任何不受信任的代码。+++您必须假设字符串是不可变的,否则您将获得零安全性和一堆任意错误作为奖励。任何假定Java字符串可以在生产代码中进行更改的程序员肯定都是疯了。
maaartinus

7

第二个原因是性能。第一个原因是因为某些类具有重要的行为或状态,因此不应更改这些行为或状态以使系统正常工作。例如,如果我有一个“ PasswordCheck”类,并且为了建立该类,我雇了一个安全专家团队,并且该类与数百个经过充分研究和定义的协议的ATM进行通信。允许刚从大学毕业的新员工参加“ TrustMePasswordCheck”课程,扩展上述课程可能对我的系统非常有害;这些方法不应该被覆盖,仅此而已。



JVM非常聪明,即使没有子类,也可以将它们视为final,即使它们没有声明为final。
user253751 '16

由于“性能”主张被多次反驳,因此被否决。
保罗·史密斯,

7

当我需要一堂课时,我会写一堂课。如果我不需要子类,那么我不在乎子类。我确保类的行为符合预期,并且我使用该类的地方都假定该类的行为符合预期。

如果有人想继承我的班级,我想完全否认对发生的一切责任。我通过将类定为“ final”来实现。如果要对其进行子类化,请记住,在编写该类时并未考虑子类化。因此,您必须获取类源代码,删除“最终”,从那时起发生的一切事情完全由您负责

您认为那是“不是面向对象的”吗?报酬让我去做一个应该做的事情。没有人为使班级可以继承而付钱给我。如果您获得报酬以使我的课程可重复使用,欢迎您这样做。首先删除“ final”关键字。

(除此之外,“最终”通常允许进行实质性的优化。例如,在Swift的“ final”中,对公共类或公共类的方法而言,这意味着编译器可以完全知道方法调用将执行什么代码,并可以用静态调度(微小的收益)代替动态调度,并且经常用内联(可能是巨大的收益)代替静态调度。

adelphus:“如果您想对其进行子类化,获取源代码,删除'final',这是您的责任”,那么很难理解吗?“最终”等于“公平警告”。

而且我没有付钱做可重用的代码。我有报酬编写符合其预期功能的代码。如果我有报酬来编写两个相似的代码,我会提取公共部分,因为这样便宜,而且我也不会浪费时间。使代码可重用而不被重用浪费我的时间。

M4ks:您总是将不应从外部访问的所有内容设为私有。同样,如果要子类化,则可以获取源代码,并根据需要将其更改为“ protected”,并对所做的事情负责。如果您认为需要访问我标记为私有的内容,那么最好知道您在做什么。

两者:子类化是重用代码的很小一部分。创建无需子类化即可适应的构建基块的功能要强大得多,并且可以从“最终”中受益匪浅,因为这些基块的用户可以依赖于所获得的东西。


4
-1此答案描述了软件开发人员所犯的所有错误。如果有人想通过子类重用您的类,请让他们。为什么使用(或滥用)它是您的责任?基本上,你正在使用最终为AF K优,你没有使用我的课。*“没有人付钱给我做一个可以被继承的类”。你是认真的吗?这就是雇用软件工程师的原因-创建可靠的,可重用的代码。
adelphus

4
-1只是将所有内容
设为

3
@adelphus虽然这个答案的措词很钝,几乎是苛刻的,但这并不是“错误”的观点。实际上,这与迄今为止对这个问题的大多数答案持相同观点,只是临床上的语气较少。
NemesisX00

+1表示您可以删除“最终”。要求对代码的所有可能用途有所了解是自大的。但是,要坦白地说,您无法维护某些可能的用途,而这些用途将需要维护叉子。
gmatht '16

4

假设平台的SDK包含以下类:

class HTTPRequest {
   void get(String url, String method = "GET");
   void post(String url) {
       get(url, "POST");
   }
}

应用程序将此类归为一类:

class MyHTTPRequest extends HTTPRequest {
    void get(String url, String method = "GET") {
        requestCounter++;
        super.get(url, method);
    }
}

一切都很好,但是使用SDK的人认为将方法传递给get它很愚蠢,并使接口更好,确保强制实现向后兼容性。

class HTTPRequest {
   @Deprecated
   void get(String url, String method) {
        request(url, method);
   }

   void get(String url) {
       request(url, "GET");
   }
   void post(String url) {
       request(url, "POST");
   }

   void request(String url, String method);
}

一切似乎都很好,直到上面的应用程序用新的SDK重新编译为止。突然,重写的get方法不再被调用,并且请求也没有被计数。

这称为脆弱的基类问题,因为看似无害的更改会导致子类破裂。任何时候在类内部调用方法的更改都可能导致子类中断。这往往意味着几乎任何更改都可能导致子类中断。

Final阻止任何人继承您的班级。这样,可以更改类中的哪些方法,而不必担心有人会完全依赖于哪个方法调用。


1

Final有效地意味着您的类将来可以安全地更改,而不会影响任何基于下游继承的类(因为没有),或者有关该类的线程安全的任何问题(我认为在某些情况下,字段上的final关键字会阻止一些基于线程的高Jinx)。

Final意味着您可以自由地更改类的工作方式,而不会在行为上发生任何意外更改,而这些更改会以其他人的代码为依托。

例如,我编写了一个名为HobbitKiller的类,这很不错,因为所有的霍比特人都是捣蛋鬼,应该死了。从头开始,他们肯定都必须死。

您可以将其用作基类,并添加一种很棒的新方法来使用喷火器,但可以将我的类用作基类,因为我有一种针对霍比特人的好方法(除了技巧十足之外,它们都很快捷),这你用来帮助瞄准你的喷火器。

三个月后,我更改了定位方法的实现。现在,在将来某个未知的升级库的时候,由于所依赖的(通常是不可控制的)超类方法的变化,类的实际运行时实现已发生根本性的变化。

因此,对我来说,要成为一名认真的开发人员,并确保在使用我的课程时将霍比特人的命运平稳地带到未来,我必须非常非常谨慎地对待任何可扩展的课程进行任何更改。

通过消除扩展的能力(除非我特别打算扩展课程),我为自己(以及希望其他人)省去了很多麻烦。


2
如果您的定位方式要改变,为什么要公开?而且,如果您极大地改变了一个班级的行为,则需要另一个版本而不是过度保护final
M4ks

我不知道为什么这会改变。霍比特人是骗人的,需要进行更改。关键是,如果我将其构建为最终版本,则可以防止继承,该继承可以保护他人,使我的更改不会感染他们的代码。
Scott Taylor

0

使用final绝不违反SOLID原则。不幸的是,将开放/封闭原则(“软件实体应该开放以进行扩展,而封闭以进行修改”)解释为“而不是修改类,为其子类化并添加新功能”,这是极为普遍的。这并不是它最初的含义,通常被认为不是实现其目标的最佳方法。

遵守OCP的最佳方法是,通过专门提供抽象行为来设计类中的扩展点,这些抽象行为通过向对象注入依赖项来参数化(例如,使用“策略”设计模式)。这些行为应设计为使用接口,以便新的实现不依赖于继承。

另一种方法是使用其公共API作为抽象类(或接口)来实现您的类。然后,您可以产生可以插入相同客户端的全新实现。如果您的新界面需要与原始界面大致相似的行为,则可以:

  • 使用Decorator设计模式重用原始对象的现有行为,或者
  • 重构要保留在helper对象中的部分行为,并在新实现中使用相同的helper(重构不是修改)。

0

对我来说,这是设计问题。

假设我有一个计算员工工资的程序。如果我有一个班级返回基于国家的两个日期之间的工作日数(每个国家/地区一个班级),则将其放在最后,并为每个企业提供一种仅为其日历提供免费工作日的方法。

为什么?简单。假设某个开发人员想要在WorkingDaysUSAmyCompany类中继承基类WorkingDaysUSA并对其进行修改,以反映出他的企业将因罢工/维护/无论火星2号为何而关闭。

客户订单和交付的计算将反映延迟并在运行时调用WorkingDaysUSAmyCompany.getWorkingDays()时进行相应的工作,但是当我计算休假时间时会怎样?我应该将火星2号作为每个人的假期吗?不会。但是由于程序员使用继承,而我没有保护类,因此可能导致混乱。

或者说,他们继承并修改了班级,以反映该公司在星期六不工作,而在该国,星期六在该国工作一半。然后,地震,电力危机或某些情况使总统宣布像最近在委内瑞拉发生的那样三个工作日不工作。如果继承类的方法每个星期六都已减去,则我对原始类的修改可能导致同一天减去两次。我将不得不转到每个客户端上的每个子类,并验证所有更改是否兼容。

解?将类设置为final,并提供一个addFreeDay(companyID mycompany,Date freeDay)方法。这样,您可以确保在调用WorkingDaysCountry类时,它是您的主类而不是子类

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.