Java中继承中的"this"关键字是如何工作的?

29
在下面的代码片段中,结果真的很令人困惑。
public class TestInheritance {
    public static void main(String[] args) {
        new Son();
        /*
        Father father = new Son();
        System.out.println(father); //[1]I know the result is "I'm Son" here
        */
    }
}

class Father {
    public String x = "Father";

    @Override
    public String toString() {
       return "I'm Father";
    }

    public Father() {
        System.out.println(this);//[2]It is called in Father constructor
        System.out.println(this.x);
    }
}

class Son extends Father {
    public String x = "Son";

    @Override
    public String toString() {
        return "I'm Son";
    }
}

结果是

I'm Son
Father
为什么在Father构造函数中,“this”指向Son,但是“this.x”指向Father的“x”字段?“this”关键字是如何工作的?
我知道多态的概念,但[1]和[2]之间不会有区别吗?当触发new Son()时,在内存中发生了什么?

13
你无法覆盖一个字段。如果你尝试,会发生奇怪的事情。 - user2357112
11
这种情况说明像TIOBE这样的度量标准完全没有意义。这种愚蠢、简单和基础的问题(在互联网上已经被问了很多次,包括在 Stack Overflow 上)两分钟内就会得到十个赞,而我的简短三行回答在五分钟内就会得到六个赞。基于这种流量来衡量一门语言的流行度是完全没有意义的,因为流量的质量也很重要。 - Manu343726
2
@Nick,如果你是一个初学者,那么这个问题本身是一个非常合理的问题。我的意思是,SO被设计为一个知识库,有好的问题和好的答案。因此,一些问题有很多赞,反映了该主题的重要性。但这是一个关于多态性的基本问题,在互联网上和特别是SO上已经直接或间接地回答了很多次。 - Manu343726
3
@Nick,我希望这种基础问题的回答者提供其他参考链接(在SO内,例如重复问题),以避免在SO网络上重复已有的知识,而不是大量点赞该问题。正如我所说的,如果我发布这个问题,我相信C ++人会做到这一点。 - Manu343726
1
@Manu343726:你有重复的候选项吗? - Peter Mortensen
显示剩余3条评论
7个回答

23
所有成员函数在Java中默认是多态的。这意味着当您调用this.toString()时,Java使用动态绑定来解析调用,调用子版本。当您访问成员x时,您访问当前作用域(父级)的成员,因为成员不是多态的。

1
嗨@Manu,我已经编辑了这个问题,你能再看一下吗?实际上我知道多态是如何工作的,但是我不清楚当执行“_new Son()_”时在内存中发生了什么,例如“This”指向哪个区域?儿子和父亲的“This”有什么区别? - Garnett
1
没有任何区别,都是一样的。这是一个引用(指针),指向你正在处理的对象。当你实例化一个子类时,首先调用基类的构造函数,因此执行父构造函数的代码。由于Java方法是多态的,如果你在基类中调用一个被派生类覆盖的方法(例如你的示例中的toString方法),则派生类的版本将被调用。 - Manu343726
@Garnett 记住:this 指向对象。但是当你在基类的范围内时,它不知道它的动态类型是否是派生类型而不是该类型(在这种情况下是父类)。因此,如果你在父类的范围内,this 就像只是一个父类一样,所以 x 是父类的 x。 - Manu343726
但是,@Manu,我认为有两个对象,一个是Son的实例,另一个是Father的实例,对吗?那么,它们如何使用相同的this?在内存中会是什么样子? - Garnett
1
Println(this)是printlm(this.toString())的简写。而toString()是您在子类上重写的多态函数。因此,子版本被解析,因为那是对象的真实类型(即使在基类代码中,您也无法知道这一点。这就是多态的要点)。 - Manu343726
显示剩余4条评论

13

这里发生了两件事情,我们来看一下:

首先,您正在创建两个不同的字段。查看(非常隔离的)字节码块,可以看到以下内容:

class Father {
  public java.lang.String x;

  // Method descriptor #17 ()V
  // Stack: 2, Locals: 1
  public Father();
        ...
    10  getstatic java.lang.System.out : java.io.PrintStream [23]
    13  aload_0 [this]
    14  invokevirtual java.io.PrintStream.println(java.lang.Object) : void [29]
    17  getstatic java.lang.System.out : java.io.PrintStream [23]
    20  aload_0 [this]
    21  getfield Father.x : java.lang.String [21]
    24  invokevirtual java.io.PrintStream.println(java.lang.String) : void [35]
    27  return
}

class Son extends Father {

  // Field descriptor #6 Ljava/lang/String;
  public java.lang.String x;
}

重要的是第13、20和21行;其它代表System.out.println();本身,或者是隐式的return;aload_0加载this引用,getfield从对象中检索字段值,在这种情况下是从this中检索。您在此处看到的是字段名称被限定为Father.x。在Son中的一行中,您可以看到有一个单独的字段。但是Son.x从未被使用,只有Father.x

现在,如果我们删除Son.x并添加以下构造函数会怎样:

public Son() {
    x = "Son";
}

首先看字节码:

class Son extends Father {
  // Field descriptor #6 Ljava/lang/String;
  public java.lang.String x;

  // Method descriptor #8 ()V
  // Stack: 2, Locals: 1
  Son();
     0  aload_0 [this]
     1  invokespecial Father() [10]
     4  aload_0 [this]
     5  ldc <String "Son"> [12]
     7  putfield Son.x : java.lang.String [13]
    10  return
}

第4、5和7行都是正确的:this"Son"已被加载,并使用putfield设置了字段。为什么要使用Son.x?因为JVM可以找到继承的字段。但需要注意的是,即使该字段被引用为Son.x,JVM找到的实际上是Father.x

那么它是否输出了正确的结果呢?不幸的是,没有:

I'm Son
Father

问题在于语句的顺序。在字节码中,第0行和第1行是隐式的super();调用,因此语句的顺序如下:

答案:问题在于语句的顺序。在字节码中,第0行和第1行是隐式的super();调用,因此语句的顺序如下:

System.out.println(this);
System.out.println(this.x);
x = "Son";

当然会打印"Father"。为了消除这个问题,可以采取一些措施。

可能最干净的方法是:不要在构造函数中打印!只要构造函数没有完成,对象就没有完全初始化。您假设由于println是构造函数中的最后一个语句,因此对象已经完成。但是您可能会遇到子类的情况,因为在子类初始化对象之前,超类构造函数将始终完成。

有些人认为这是构造函数本身的缺陷;有些语言甚至不使用构造函数。您可以使用init()方法。在普通方法中,您具有多态的优势,因此可以在Father引用上调用init(),从而调用Son.init();而new Father()始终创建Father对象。 (当然,在Java中,您仍然需要在某个时候调用正确的构造函数)。

但我认为您需要的是像这样的东西:

class Father {
    public String x;

    public Father() {
        init();
        System.out.println(this);//[2]It is called in Father constructor
        System.out.println(this.x);
    }

    protected void init() {
        x = "Father";
    }

    @Override
    public String toString() {
        return "I'm Father";
    }
}

class Son extends Father {
    @Override
    protected void init() {
        //you could do super.init(); here in cases where it's possibly not redundant
        x = "Son";
    }

    @Override
    public String toString() {
        return "I'm Son";
    }
}

我没有给它取名,但是试一下。它会打印出来。

I'm Son
Son

那么这里到底发生了什么?你最上面的构造函数(即Father)调用了一个init()方法,这个方法在子类中被覆盖。由于所有构造器都会先调用super();,它们实际上是按照从超类到子类的顺序执行的。因此,如果最上面的构造函数的第一个调用是init();,那么所有的初始化都将在任何构造函数代码之前发生。如果你的init()方法完全初始化了对象,那么所有的构造函数都可以使用已初始化的对象。并且由于init()是多态的,即使有子类存在,它也可以对对象进行初始化,而构造器就不行。

请注意,init()是受保护的:子类将能够调用和覆盖它,但其他包中的类将无法调用它。这比public稍微好一点,应该也考虑用于x


7

虽然方法可以被覆盖,但属性可以被隐藏。

在您的情况下,属性x被隐藏:在Son类中,除非使用super关键字,否则无法访问Fatherx值。 Father类不知道Sonx属性。

相反,toString()方法被覆盖:始终调用实例化类的实现(除非它没有覆盖它),即在您的情况下是Son,无论变量类型是什么(ObjectFather等)。


7
正如其他人所述,您无法覆盖字段,只能隐藏它们。请参见JLS 8.3. Field Declarations
如果类声明了一个特定名称的字段,则该字段的声明被认为隐藏了超类和类的超级接口中与该名称相同的所有可访问字段的声明。
在这方面,字段隐藏与方法隐藏不同(参见§8.4.8.3),因为在字段隐藏中没有区分静态字段和非静态字段,而在方法隐藏中区分静态方法和非静态方法。
如果一个字段是静态的,可以使用限定名称(§6.5.6.2)来访问它,或者可以使用包含关键字 super(§15.11.2)或向超类类型进行转换的字段访问表达式来访问它。
在这方面,字段隐藏与方法隐藏类似。
一个类从其直接超类和直接超级接口继承所有非私有字段的超类和超级接口,在类中对其具有访问权限且未被类中的声明隐藏。
你可以使用super关键字从Son的作用域访问Father的隐藏字段,但反过来是不可能的,因为Father类不知道它的子类存在。

2

多态方法调用仅适用于实例方法。您始终可以使用更一般的引用变量类型(超类或接口)引用对象,但在运行时,仅基于实际对象(而非引用类型)选择的动态方法是实例方法而不是静态方法或变量。仅重写的实例方法是根据真实对象类型进行动态调用的。

因此,变量x没有多态行为,因为它不会在运行时被动态选中

解释您的代码:

System.out.println(this);

对象类型为Son,因此将调用toString()方法的重写Son版本。

System.out.println(this.x);

这里不涉及对象类型,this.xFather类中,因此将打印x变量的Father版本。

更多内容请参见:Java中的多态性


2

这是一种特殊的行为,旨在访问私有成员。因此,this.x查看Father中声明的变量X,但当您将此作为参数传递给Father中的方法中的System.out.println时,它会根据参数类型查找要调用的方法-在您的情况下是Son。

那么如何调用超类的方法?使用super.toString()等。

从Father中无法访问Son的x变量。


1
这通常被称为“阴影处理”。请注意您的类声明:
class Father {
    public String x = "Father";

并且

class Son extends Father {
    public String x = "Son";

当创建Son实例时,会创建2个名为x的不同变量。一个x属于Father超类,另一个x属于Son子类。根据输出,我们可以看到在Father作用域中,this访问Fatherx实例变量。因此,行为与"指向什么"无关;这是运行时如何搜索实例变量的结果。它只在类层次结构中向上搜索变量。类只能引用自身和其父类的变量;它不能直接访问其子类的变量,因为它对其子类一无所知。
为了获得所需的多态行为,您应该只在Father中声明x
class Father {
    public String x;

    public Father() {
        this.x = "Father"
    }

并且

class Son extends Father {
    public Son() {
        this.x = "Son"
    }

本文讨论了您所经历的行为:http://www.xyzws.com/Javafaq/what-is-variable-hiding-and-shadowing/15

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接