【问题标题】:What's wrong with overridable method calls in constructors?构造函数中可覆盖的方法调用有什么问题?
【发布时间】:2011-03-25 04:12:23
【问题描述】:

我有一个 Wicket 页面类,它根据抽象方法的结果设置页面标题。

public abstract class BasicPage extends WebPage {

    public BasicPage() {
        add(new Label("title", getTitle()));
    }

    protected abstract String getTitle();

}

NetBeans 用“构造函数中的可覆盖方法调用”消息警告我,但它应该有什么问题?我能想象的唯一选择是将其他抽象方法的结果传递给子类中的超级构造函数。但是使用很多参数可能很难阅读。

【问题讨论】:

  • 我是一名 .NET 开发人员,但是看到了这个并且很想知道为什么它会发出警告,因为我有时会在 C# 中做类似的事情。这篇文章似乎说明了为什么它是一个警告:javapractices.com/topic/TopicAction.do?Id=215 所以这一切都与初始化对象层次结构的时间和顺序有关。
  • 在 C# 中我们也有同样的问题:msdn.microsoft.com/en-us/library/ms182331.aspx
  • 这提醒我检查 IntelliJ 是否给出此警告...

标签: java oop inheritance constructor overriding


【解决方案1】:

关于从构造函数调用可重写方法

简单地说,这是错误的,因为它不必要地为 许多 错误打开了可能性。当调用@Override 时,对象的状态可能不一致和/或不完整。

引自Effective Java 2nd Edition,Item 17: Design and document for inheritance,或者禁止它

为了允许继承,类必须遵守更多的限制。 构造函数不得直接或间接调用可覆盖的方法。如果您违反此规则,将导致程序失败。超类构造函数在子类构造函数之前运行,因此子类中的覆盖方法将在子类构造函数运行之前被调用。如果覆盖方法依赖于子类构造函数执行的任何初始化,则该方法将不会按预期运行。

这里有一个例子来说明:

public class ConstructorCallsOverride {
    public static void main(String[] args) {

        abstract class Base {
            Base() {
                overrideMe();
            }
            abstract void overrideMe(); 
        }

        class Child extends Base {

            final int x;

            Child(int x) {
                this.x = x;
            }

            @Override
            void overrideMe() {
                System.out.println(x);
            }
        }
        new Child(42); // prints "0"
    }
}

这里,当Base构造函数调用overrideMe时,Child还没有完成对final int x的初始化,方法得到了错误的值。这几乎肯定会导致错误和错误。

相关问题

另见


关于多参数的对象构造

具有许多参数的构造函数可能会导致可读性差,但存在更好的替代方案。

这里引用Effective Java 2nd Edition,Item 2:在面对许多构造函数参数时考虑构建器模式

传统上,程序员使用伸缩构造函数模式,在该模式中,您提供一个仅具有必需参数的构造函数,另一个具有单个可选参数,第三个具有两个可选参数,依此类推。 ..

伸缩构造函数模式本质上是这样的:

public class Telescope {
    final String name;
    final int levels;
    final boolean isAdjustable;

    public Telescope(String name) {
        this(name, 5);
    }
    public Telescope(String name, int levels) {
        this(name, levels, false);
    }
    public Telescope(String name, int levels, boolean isAdjustable) {       
        this.name = name;
        this.levels = levels;
        this.isAdjustable = isAdjustable;
    }
}

现在您可以执行以下任何操作:

new Telescope("X/1999");
new Telescope("X/1999", 13);
new Telescope("X/1999", 13, true);

但是,您目前不能只设置nameisAdjustable,而将levels 保留为默认值。你可以提供更多的构造函数重载,但显然这个数量会随着参数数量的增加而爆炸式增长,你甚至可能有多个booleanint 参数,这真的会让事情变得一团糟。

如您所见,这不是一个令人愉快的编写模式,使用起来更不愉快(这里的“真”是什么意思?13 是多少?)。

Bloch 建议使用构建器模式,这样您就可以编写如下代码:

Telescope telly = new Telescope.Builder("X/1999").setAdjustable(true).build();

请注意,现在参数已命名,您可以按您想要的任何顺序设置它们,并且您可以跳过您想要保留默认值的那些。这肯定比伸缩构造函数好得多,尤其是当有大量参数属于许多相同类型时。

另见

相关问题

【讨论】:

  • +1。有趣的。我想知道 C# 中的对象初始值设定项是否会使伸缩构造函数和 Builder 模式都变得不必要。
  • @Johannes:在 Java 中,实例初始化程序在第 4 步执行,在第 3 步的超类构造函数之后,在创建新实例 java.sun.com/docs/books/jls/third_edition/html/… 时执行;不过,我不确定这是否能解决您的评论。
  • 也就是说,Java 没有进行 2 阶段初始化太糟糕了:第 1 遍用于方法 定义,第 2 遍用于执行构造函数。现在我要为某些工厂模式或其他模式编写更多代码。呜呜。我想要的只是从一个纯函数中设置一些默认数据,这些数据可以在子类中交换,或者在构造和使用之间更新。
  • Android 工程师注意:android 视图的可重写方法 invalidate() 有时会在视图的构造函数中调用。
  • 仅供参考:引用的句子“如果您违反此规则,将导致程序失败。”是彻头彻尾的谎言。然而,它更有可能在未来产生。
【解决方案2】:

这是一个有助于理解这一点的例子:

public class Main {
    static abstract class A {
        abstract void foo();
        A() {
            System.out.println("Constructing A");
            foo();
        }
    }

    static class C extends A {
        C() { 
            System.out.println("Constructing C");
        }
        void foo() { 
            System.out.println("Using C"); 
        }
    }

    public static void main(String[] args) {
        C c = new C(); 
    }
}

如果你运行这段代码,你会得到以下输出:

Constructing A
Using C
Constructing C

你看到了吗? foo() 在 C 的构造函数运行之前使用 C。如果foo() 要求 C 具有已定义的状态(即构造函数已完成),那么它将在 C 中遇到未定义的状态并且事情可能会中断。由于您无法在 A 中知道被覆盖的 foo() 期望什么,因此您会收到警告。

【讨论】:

    【解决方案3】:

    在构造函数中调用一个可覆盖的方法允许子类颠覆代码,所以你不能保证它不再起作用。这就是您收到警告的原因。

    在您的示例中,如果子类覆盖 getTitle() 并返回 null 会发生什么?

    要“修复”这个问题,您可以使用 factory method 代替构造函数,这是一种常见的对象实例化模式。

    【讨论】:

    • 返回 null 是破坏许多接口的一般问题。
    • 返回 null 是一个特殊问题,当它发生在由超级构造函数调用的重写方法中时。
    【解决方案4】:

    这是一个示例,揭示了在超级构造函数中调用可覆盖方法时可能发生的逻辑问题

    class A {
    
        protected int minWeeklySalary;
        protected int maxWeeklySalary;
    
        protected static final int MIN = 1000;
        protected static final int MAX = 2000;
    
        public A() {
            setSalaryRange();
        }
    
        protected void setSalaryRange() {
            throw new RuntimeException("not implemented");
        }
    
        public void pr() {
            System.out.println("minWeeklySalary: " + minWeeklySalary);
            System.out.println("maxWeeklySalary: " + maxWeeklySalary);
        }
    }
    
    class B extends A {
    
        private int factor = 1;
    
        public B(int _factor) {
            this.factor = _factor;
        }
    
        @Override
        protected void setSalaryRange() {
            this.minWeeklySalary = MIN * this.factor;
            this.maxWeeklySalary = MAX * this.factor;
        }
    }
    
    public static void main(String[] args) {
        B b = new B(2);
        b.pr();
    }
    

    结果实际上是:

    minWeeklySalary: 0

    maxWeeklySalary: 0

    这是因为 B 类的构造函数首先调用了 A 类的构造函数,其中 B 内部的可覆盖方法被执行。但是在方法内部我们使用了实例变量factor,它还没有被初始化(因为A的构造函数还没有完成),因此factor是0而不是1绝对不是 2(程序员可能认为它会是的东西)。想象一下,如果计算逻辑扭曲十倍,跟踪错误将是多么困难。

    我希望这会对某人有所帮助。

    【讨论】:

      【解决方案5】:

      如果您在构造函数中调用子类覆盖的方法,这意味着如果您在构造函数和方法之间逻辑划分初始化,则不太可能引用尚不存在的变量。

      查看此示例链接http://www.javapractices.com/topic/TopicAction.do?Id=215

      【讨论】:

        【解决方案6】:

        在 Wicket 的具体案例中:这就是我问 Wicket 的原因 开发人员在构建组件的框架生命周期中添加对显式两阶段组件初始化过程的支持,即

        1. 构造 - 通过构造函数
        2. 初始化 - 通过 onInitilize(在虚拟方法工作时构造之后!)

        关于是否有必要(恕我直言,这完全是必要的)存在相当激烈的辩论,因为此链接显示http://apache-wicket.1842946.n4.nabble.com/VOTE-WICKET-3218-Component-onInitialize-is-broken-for-Pages-td3341090i20.html

        好消息是,Wicket 的优秀开发人员最终确实引入了两阶段初始化(让最棒的 Java UI 框架更加出色!),因此使用 Wicket,您可以在 onInitialize 方法中完成所有构建后初始化如果您覆盖它,框架会自动调用它 - 在组件生命周期的这一点上,它的构造函数已完成其工作,因此虚拟方法按预期工作。

        【讨论】:

          【解决方案7】:

          我猜对于 Wicket,最好在 onInitialize() 中调用 add 方法(参见 components lifecycle):

          public abstract class BasicPage extends WebPage {
          
              public BasicPage() {
              }
          
              @Override
              public void onInitialize() {
                  add(new Label("title", getTitle()));
              }
          
              protected abstract String getTitle();
          }
          

          【讨论】:

            【解决方案8】:

            我当然同意在某些情况下最好不要从构造函数中调用某些方法

            将它们设为私有可以消除所有疑虑:"You shall not pass"

            但是,如果您确实想保持开放状态怎么办。

            不仅仅是访问修饰符才是真正的问题,正如我试图解释的here。老实说,private 是一个明显的亮点,protected 通常仍然允许(有害的)解决方法。

            更一般的建议:

            • 不要从您的构造函数中启动线程
            • 不要从构造函数中读取文件
            • 不要从您的构造函数调用 API 或服务
            • 不要通过构造函数从数据库中加载数据
            • 不要从您的构造函数中解析 json 或 xml 文档

            不要(in)直接从您的构造函数中这样做。这包括从构造函数调用的私有/受保护函数中执行任何这些操作。

            从您的构造函数调用 start() 方法肯定是一个危险信号。

            相反,您应该提供 public init()start()connect() 方法。并将责任留给消费者。

            简单地说,您希望将“准备”的时刻与“点火”的时刻分开

            • 如果可以扩展构造函数,则它不应自燃。
            • 如果它自燃,那么它就有可能在完全构建之前被发射。
            • 毕竟,有朝一日可以在子类的构造函数中添加更多准备工作。而且您无法控制超类构造函数的执行顺序。

            PS:考虑实现Closeable 接口。

            【讨论】:

              猜你喜欢
              • 2022-11-13
              • 2014-01-18
              • 2013-03-01
              • 1970-01-01
              • 1970-01-01
              • 2011-08-31
              • 1970-01-01
              • 2014-05-22
              相关资源
              最近更新 更多