02_oo_08_overloading_overriding_shadowing_hiding_obscuring

第二十一章 与命名冲突相关的五个重要概念

1 简介

本章介绍与命名冲突相关的五个重要概念。它们分别是重载(Overloading),覆盖(Overriding),遮蔽(Shadowing),隐藏(Hiding)和遮盖(Obscuring)。遮蔽(Shadowing)和遮盖(Obscuring)的中文翻译可能不太准确,但是,笔者未能找到更为贴切的中文术语。请读者在阅读本章时将更多的注意力放在概念的理解上。在实际运用中,多使用英文术语,以避免引起不必要的混淆。

2 重载 (Overloading)

重载(Overloading)的概念主要运用于类中的成员方法上。当一个类中包含了两个或者多个成员方法,它们的名字相同且它们的函数定义不能相互覆盖(Not Override-Equivalent)时,它们被称为重载函数。一个类的重载函数可能来源于该类声明或者定义的函数,或者继承自父类或者父接口的函数。函数之间不能相互覆盖是指函数的签名(Method Signature)不同(参数个数不同,参数类型的顺序不同,或者至少有一个参数的类型不同)。函数的返回类型或者可能抛出异常的类型不在考虑之列。

在编译时,Java编译器会检查函数的参数列表以及各个实参(Arguments)的类型,以确定哪个重载函数会被使用。一旦被调用的重载函数被确定下来后,如果它是类的成员函数,那么,实际上被调用的函数会在运行时确定,这也是Java多态(Polymorphism)的特性。

在如下的例子中,RealPoint类有两个重载函数move,分别接受两个int类型和float类型的参数。

class Point {
    int x = 0;
    int y = 0;

    public void move(int dx, int dy) { 
        x += dx; 
        y += dy; 
    }
}

class RealPoint extends Point {
    float x = 0.0f;
    float y = 0.0f;

    void move(int dx, int dy) { 
        move((float)dx, (float)dy); 
    }

    void move(float dx, float dy) { 
        x += dx; 
        y += dy; 
    }
}

站在编程语言设计与实现的角度看,函数重载功能并不是必须的。但是,站在开发者的角度看,如果一些函数的实现内容大致相同,只是因为调用场景不同而有着不同参数的话,开发人员更倾向于使用相同的函数名称调用这些函数,这使得代码逻辑更加清晰、通畅。

3 覆盖 (Overriding)

在类的继承机制中,当一个类Derived继承自它的直接父类Base时,Derived会继承Base中所有的public或者protected的成员函数和静态函数。上述说明有一个例外,即当Derived类中包含有成员函数,它的函数签名(Method Signature)与Base中的某个成员函数相同时,Derived中的函数会覆盖Base中的函数。这也被称为成员方法覆盖。当Base或者Derived是泛型类时,或者当它们的成员方法是泛型函数时,函数覆盖的规则将变得非常复杂,我们会在单独的章节中介绍。

当某个父类的成员方法被继承类的成员方法覆盖时,在多态机制的作用下,Java虚拟机会在运行时查找并调用继承类的函数。因此,基类的成员函数被“覆盖”,不会被调用。

例如,在上例中,Point.move()函数被第一个RealPoint.move()函数覆盖。因此,下面的代码将调用RealPoint.move()函数。

Point p = new RealPoint();
p.move(1, 1);

函数覆盖功能是面向对象程序设计中继承机制的有益补充。因为继承机制的主要目的是为了复用代码(Code Reuse)。比如,子类可以继承使用父类定义的成员变量和成员方法(public或者protected)。然而,在继承过程中,子类难免会有着和父类不同的实现逻辑,而且这些不同的逻辑可能需要隐藏在类中,不暴露于外部。因此,函数覆盖是解决这个问题的一种方法,也是绝大多数面向对象程序设计语言所采用的方法。

4 隐藏 (Hiding)

在类的继承机制中,当一个类Derived继承自它的父类Base时,如果类Derived和类Base同时定义了相同的静态函数(函数名字相同,函数的签名相同),则Derived的静态函数隐藏了Base类的函数。例如,在下例中,main函数调用的是RealPoint类的display函数。

class Point {
    public static void display(Point p) {
        System.out.println("Point::display is invoked.");
    }
}

class RealPoint extends Point {
    public static void display(Point p) {
        System.out.println("RealPoint::display is invoked.");
    }
    public static void main(String[] args) {
        RealPoint.display(new RealPoint()); // RealPoint::display is invoked.
    }
}

5 遮蔽 (Shadowing)

遮蔽(Shadowing)指的是在某一个作用域中定义的变量会遮蔽在该作用域外定义的相同名字的变量。简而言之,当使用一个变量时,Java编译器会首先在当前的作用域寻找该变量的定义。如果在当前作用域未能找到,则再在外层的作用域中寻找。以此类推。在下面的例子中,System.out.println打印的是类型为float的变量x,因为这个变量x是在当前的作用域中定义的。它遮蔽了在外层定义的int类型的变量x。

public class ShadowingExample {
    public void shadow() {
        int x = 0;
        x++;
        {
            float x = 2.0L;
            System.out.println(x);
        }
    }
}

6 遮盖 (Obscuring)

当在同一个上下文中,变量名(Variable Name)、类型名(Type Name)和包名(Package Name)恰巧相同时,Java编译器会首先选择变量名、其次才是类型名、最后是包名。换句话说,如果在同一个上下文中,某标识符(Identifier)既可以被认为是某个变量,或者类型名称的话,Java编译器会认为这个标识符表示的是变量。同理,如果在某一上下文中,某标识符既可以被认为是类型名称,也可以被认为是包的名称的话,Java编译器会认为这个标识符表示的是类型名称。所以,下面的例子是不能通过编译的,因为Java编译器会认为System是变量名,而不是包名。

Java语言标准文档是这样描述的。其中,规则6.5.2指的是当标识符名字发生冲突时Java编译器需要运用的规则。

In these situations, the rules of §6.5.2 specify that a variable will be chosen in preference to a type, and that a type will be chosen in preference to a package. Thus, it is may sometimes be impossible to refer to a type or package via its simple name, even though its declaration is in scope and not shadowed. We say that such a declaration is obscured. --- Java SE 13 Specification

public class ObscuringExample {
    public void obscure() {
        String System = "";
        System.out.println(10);
    }
}

为了避免上述例子的错误,Java开发命名规范一般要求类名首字母大写,变量名首字母小写,静态变量名全部大写。如果在开发时遵守此规范,可完全避免遮盖(Obscuring)的情况发生。

7 结语

本章介绍了5个易于混淆的概念。这些概念适用于函数命名或者变量命名冲突的情况。从上述的解释和用例可以看出,合理的使用一套命名规则能降低代码的复杂性,也能避免一些低级的代码错误。

上一章
下一章

注册用户登陆后可留言

Copyright  2019 Little Waterdrop, LLC. All Rights Reserved.