Java的几个术语(覆写, 重载, 隐藏, 遮蔽, 遮掩)

这几天看Java Puzzler,被里面的题目折腾得死去活来,特别是标题里的几个概念。于是决定发篇小文澄清下几个概念。
覆写(Override)
即子类的实例方法覆写了父类的实例方法,即函数的多态。
陷阱1:覆写仅仅对于实例方法有效,对于属性和静态方法不适用。后两者的情况属于隐藏(hide)的范畴。覆写的函数调用时动态绑定的,根据实际类型进行调用;而隐藏的调用则是静态绑定,编译期间就确定了。
陷阱2:覆写要求父类函数的参数和子类函数的参数类型相同。举两个例子:

        public class Base{
                public void foo(Object a){}
                public void bar(int a){}
        }
        public class Derived extends Base{
                @Override
                public void foo(String a){}
                @Override
                public void bar(Integer a){}
        }

上面的代码能通过编译吗?结论是都不行。Java要求类型严格的一致,所以上面的例子实际上表现的是函数的重载。
正如例子中写的那样,对于Java5以上的版本,确定子类函数是否覆写了父类函数,只需要给子类函数添加一个@Override注释,如果子类函数并未覆写任何父类函数,则无法通过编译。
陷阱3:覆写要求父类函数的访问限制对子类可见(public/protected/package)。上例子:

        public class Base{
                private void foo(){System.out.println("base foo");}
                public void bar(){foo();}
        }
        public class Derived extends Base{
                public void foo(){System.out.println("derived foo");}
                public void static main(String[] args){new Derived().bar();}
        }

上面的例子将会输出base foo。子类并没有覆写父类的函数。
在Java虚拟机中,多态的调用通过invokevirtual和invokeinterface两条指令进行调用,前者用于类引用,后者用于接口引用。上面两个方法均是动态绑定。而invokestatic和invokespecial指令使用静态绑定。前者用于静态方法的调用,后者用于实例方法的调用,分为三种情况:第一种用于构造函数(<init>),显然构造函数不需要动态绑定;第二种情况用于调用私有的实例方法,也是上面这个例子里的情况,原因在于,既然这个方法不可能被子类方法覆写,所以直接使用静态绑定(不能推广到final关键字上);第三种情况用于super关键字,在子类方法中强行指定调用父类方法时使用super指代父类,但此类的调用JVM使用动态绑定,具体不再赘述,参见《深入Java虚拟机》第19章的内容。
重载(Overload)
重载这个技术,仅仅是针对语言而言,而在JVM中其实并没有重载的概念。函数的调用匹配本身是通过函数名+函数参数确定的,在匹配函数名后,再匹配函数参数。在选择相同名字的方法时,编译器会选择一个最精确的重载版本。举个例子

public class Main {
        public int a = 1;
        public static void main(String[] args){
                new Main().test(3);
                new Main().test(new Integer(3));
                new Main().test(new ArrayList());
        }
        public void test(int a){System.out.println("int");}
        public void test(Object a){System.out.println("object");}
        public void test(List a){System.out.println("intlist");}
}

上面的方法会打印int int intlist。第一个调用匹配了同为int,Integer类型的函数;第二个调用在寻找Integer类型的参数未果的情况下,把Integer自动拆箱,匹配到了int类型;第三个调用则匹配了最精确的类型List,而不是不精确的Object。如果我们增加一个Integer类型作为参数的函数:

public class Main {
        public int a = 1;
        public static void main(String[] args){
                new Main().test(3);
                new Main().test(new Integer(3));
                new Main().test(new ArrayList<int>());
        }
        public void test(int a){System.out.println("int");}
                public void test(Integer a){System.out.println("integer");}
        public void test(Object a){System.out.println("object");}
        public void test(List<int> a){System.out.println("intlist");}
}

则会精确匹配类型,打印int integer intlist。
再看一个例子,思想来自于Java Puzzlers #46

public class Main {
        public int a = 1;
        public static void main(String[] args){
                new Main().test(null);
        }
        public void test(int[] a){System.out.println("intarray");}
        public void test(Object a){System.out.println("object");}
}

这次的输出会是什么?答案是intarray。如果我们调用的是new Main().test(new int[0]),那么结果总是很好理解;而如果传递一个null,则就有违直觉了。关键在于,编译器总是需要寻找更加精确的类型,而匹配的测试并没有使用实参(参数的实际类型)进行匹配。对于编译器来说,null都可以是Object类型变量和int[]类型变量的合法值,而int[]变量可以是Object类型,但Object类型变量不一定是int[]类型,相较之下int[]更为精确,所以编译器选择了int[]。如果一定要让编译器选择Object,那我们只需要通过new Main().test((Object)null)的形式即可。编译器在重载的时候只认形式参数,不认实际参数。
如何匹配重载函数的代码,Spring里有一个查找constructor的例子,在org.springframework.beans.factory.support.ConstructorResolver的autowireConstructor()函数,以前曾经在Spring配置的时候发生过问题Trace到这里。这个匹配算法使用了贪心的思想(代码里这么注释的),对于每个候选的构造函数都通过一个评估函数(getTypeDifferenceWeight())评估与调用参数类型(constructor-arg里配置的)的相似度,最后取最相似的候选函数。有兴趣的朋友可以自己看看。
隐藏(Hide)
隐藏的概念应用于父类和子类之间。子类的域、静态方法和类型声明可以隐藏父类具有相同名称或函数签名的域、静态方法和类型声明。同final方法不能被覆写类似,final的方法也不能被隐藏。考虑下面的代码:

class Point {
        int x = 2;
}
class Test extends Point {
        double x = 4.7;
        void printBoth() {
                System.out.println(x + " " + super.x);
        }
        public static void main(String[] args) {
                Test sample = new Test();
                sample.printBoth();
                System.out.println(sample.x + " " + ((Point)sample).x);
        }
}

上面的代码将打印4.7 2 \n 4.7 2。子类中的x变量隐藏了父类中的x变量,尽管两个变量类型不同。要访问父类中的变量必须使用super关键字或者进行类型强制转换。下面的代码展示了实例变量隐藏静态变量:

class Base{
        public static String str = "Base";
}
public class Main extends Base{
    public String str = "Main";
    public  static void main(String[] args){
        System.out.println(Main.str);
    }
}

上面的代码不能正确编译,原因是子类中的实例域把父类中的静态域给隐藏了。如果去掉子类中str的声明就可以正确编译。
静态方法和类型声明的隐藏的情况,和域隐藏的情况大同小异,不再给出具体的例子。
遮蔽(shadow)
遮蔽(shadow)指的是在一个范围内(比如{}之间),同名的变量,同名的类型声明,同名的域之间的名称遮蔽。我们最常见到的遮蔽就是IDE为我们自动生成的setter:

public class Pojo{
        private int x;
        public void setX(int x){
                this.x = x;
        }
}

即使是变量类型不相同也可以遮蔽。在上个例子中把setX(int x)的参数类型改为short,也能通过编译。函数的遮蔽常见于匿名类里的函数遮蔽原先类的函数等情况。
上面的几种遮掩比较常见,下面情况就比较特殊了(来源于Java Puzzlers#71)。其中Arrays.toString()方法提供了多个重载版本,可以方便的把基本类型数组转换为字符串。

import static java.util.Arrays.toString;
public class ImportDuty{
        public static void main(String[] args){
                printArgs(1, 2, 3, 4, 5);
        }
        static void printArgs(Object... args){
                System.out.println(toString(args));
        }
}

结果是不能编译,并且返回的信息告诉我们,Object.toString()方法不适用。这是为什么呢?原因在于,Object.toString()方法把Arrays.toString()方法给遮蔽了。下面的代码虽然可以编译,但运行时会提示找不到main方法入口:

class String{
}
public final class Main{
        public static void main(String[] args){
        }
}

原因在于我们自定义的String类型把java.lang.String类型遮蔽了。而如果强行加入import java.lang.String;语句,则不能通过编译。
遮掩(obscure)
遮掩很容易与遮蔽(shadow)混淆。其最重要的区别在于,遮蔽是相同元素之间的遮蔽,变量遮蔽变量,类型声明遮蔽类型声明,函数遮蔽函数。而遮掩却是变量遮掩类型和包声明,类型声明遮掩包声明。
遮掩的例子不多,看下面的例子,来自Java Puzzlers#68:

class ShadesOfGray{
        public static void main(String[] args){
                System.out.println(X.Y.Z);
        }
}
class X{
        static class Y{
                static String Z = "Black";
        }
        static C Y = new C();
}
class C{
        String Z = "White";
}

上面的例子不仅能通过编译,还将打印White。原因就在于当一个变量和一个类型具有相同名称,且具有相同作用域时,变量名优先级大。而类型的声明又具有比包声明更高的优先级。下面一个例子也不能通过编译:

public class Obscure{
        static String System;//变量遮掩了java.lang.System类型
        public static void main(String[] args){
                System.out.println("hello, obscure world!");
        }
}

小结
只有覆写是运行时的技术,另外其他的技术都是编译期的技术。
除了重载、覆写和作为setter的遮蔽,其他特性都强烈不推荐使用,这只会给你和你的继任者带来无穷无尽的麻烦,事实上,重载我认为也少用为妙,特别是结合了使用不确定参数个数的”…”函数的重载,会使你回头看代码时晕头转向。
p.s. 准备技术笔试/面试的语言类题目,C/C++的话,程序员面试宝典里有一些题目,另外就是C++的那些effective系列的书,具体还请C++达人列举(被点名的同学请自觉回帖)。Java的话,首推Joshua Bloch的Effecitve Java;第二就是Java Puzzlers,而后者其实更适合要参加技术笔试面试的同学。剩下的书,应该还有Practical Java,不过我没看过。

5 thoughts on “Java的几个术语(覆写, 重载, 隐藏, 遮蔽, 遮掩)”

  1. 记得当年看Java解惑的时候也很头大,现在差不多都忘了,这几种情况C++里也都有,但分得没这么细
    C++里hide和遮蔽是一样的,但C++并里没有“遮掩”这个术语,标准里说明了编译时的按照类似的优先级来解释名称,并用typename显示告诉编译器后面的是类型

    1. @小翼, 多谢1楼指出几处错误。不过MS你还没忘了列出基本C++的reference吧。
      遮掩,遮蔽这些东西都是JLS里说的,都挺confusing的

  2. 看完effective+exceptional系列共六本,一般笔试面试上的c++基本题就都不怕了
    至于其它的书,可以看刘未鹏的这篇http://blog.csdn.net/pongba/archive/2007/05/16/1611593.aspx

  3. 弱问你贴代码的那个插件是怎么实现的,用wordpress? 貌似是php
    C#开发的blog,能加什么插件,贴代码时侯保留格式的?
    哪里有下

Leave a Reply

Your email address will not be published.