详解 final 修饰符

当被问到 final 修饰符,我们通常会随口而出以下3句话:

  • 被 final 修饰的变量被赋初始值后,不能再重新赋值
  • 被 final 修饰的方法不能被重写
  • 被 final 修饰的类不能被继承

仅记住这些"口诀"是不够的,本文将对 final 的这些功能进行分析

1. final 修饰变量的功能

(1) 被 final 修饰的变量被赋初始值后,不能再重新赋值

被 final 修饰的实例变量必须显示的指定初始值,而且只能在以下3个位置指定初始值:

  • 定义final实例变量时指定初始值
  • 在非静态代码块中为final实例变量指定初始值
  • 在构造器中为final实例变量指定初始值

看如下代码:

public class Test {
    
    // 定义 final 实例变量时赋初始值
    public final int x = 10;
    public final int y;
    public final int z;
    
    {
        y = 20;
    }
    
    public Test() {
        this.z = 30;
    }
    
    public static void main(String[] args) {
        Test test = new Test();
        System.out.println(test.x);
        System.out.println(test.y);
        System.out.println(test.z);
    }
}

结果为:
10
20
30

说明: final 实例变量必须显式的被赋初始值,虽然写程序的时候可以在定义final实例变量的时候、在非静态代码块中和在构造器中为final实例变量赋初始值,但本质上,这3种方式都是一样的,都是在构造器中赋值

对于final修饰的类变量而言,只能在以下两个地方赋初始值:

  • 定义final类变量时指定初始值
  • 在静态代码块中为final类变量指定初始值

以下为测试代码:

public class Test {
    
    // 定义 final 实例变量时赋初始值
    public static final int X = 10;
    public static final int Y;
    
    static {
        Y = 20;
    }
    
    public static void main(String[] args) {
        System.out.println(Test.X);
        System.out.println(Test.Y);
    }
}

结果:
10
20

说明: final 修饰的类变量必须显式的被赋初始值,虽然写程序的时候可以在定义final类变量的时候和在静态代码块中为final类变量赋初始值,但本质上,这2种方式是一样的,都是在静态代码块中赋值

final修饰的局部变量需要被显示的赋初始值,其实非final修饰的局部变量也需要显示的赋初始值,只不过被final修饰的局部变量被赋值后就不能重新赋值了。

通过以下分析我们可以得出 final 修饰变量的第一个功能:被final修饰的变量一旦被赋初始值,以后这个值将不能被改变

(2)"宏替换"

看如下代码:

class Price {
    
    public static final Price INSTANCE = new Price(2.8);
    public static double initPrice = 20.0;
    public double currentPrice;
    
    public Price(double discount) {
        currentPrice = initPrice - discount;
    }
    
}

public class Test {
    public static void main(String[] args) {
        System.out.println(Price.INSTANCE.currentPrice);
        Price p = new Price(2.8);
        System.out.println(p.currentPrice);
    }
}

结果:
-2.8
17.2

原因在详解 Java 对象与内存控制(上)的第5条目——类变量的初始化优先级中已经分析过,而如果把 initPrice 用 final 修饰,代码如下:

class Price {
    
    public static final Price INSTANCE = new Price(2.8);
    public static final double INITPRICE = 20.0;
    public double currentPrice;
    
    public Price(double discount) {
        currentPrice = INITPRICE - discount;
    }
    
}

结果为:
17.2
17.2

说明:对于一个用final修饰的变量而言,如果定义该final变量时就指定初始值,而且这个初始值可以在编译时就确定下来(比如2、4.3、"HELLO WORLD"这样的直接量),那么这个final变量将不再是一个变量,而是把它当成一个"宏变量",即不会在构造器(对于实例变量而言)或静态代码块(对于类变量而言)中去给这个final变量赋初始值,而是在类定义中直接使用该初始值来代替该final变量,也就是说,在所有出现该变量的地方,直接把它当成对应的值来处理,final的这种功能我们称之为"宏替换"

实际上,对于一个final变量,不管它是类变量、实例变量还是局部变量,只要定义该变量时指定了初始值,而且这个初始值在编译时就可以确定下来,那么这么final变量就不再是变量,而是一个直接量

public class Test {
    public static void main(String[] args) {
        final int count = 5;
        System.out.println(count);
    }
}

System.out.println(count)在编译器内部其实是System.out.println(5)

除了上面那种为final变量赋值时指定初始值为直接量的情况外,如果final变量被赋值为一个表达式,且这个表达式只是基本的算术运算或者字符串连接,没有访问普通变量,也没有调用方法,那么编译器同样会把这种final变量当做"宏变量",看如下代码:

public class Test {
    public static void main(String[] args) {
        
        // 3个"宏变量"
        final int a = 5 + 2;
        final double b = 1.2 / 3;
        final String s = "Hello " + 2018;
        
        // 下面的s2变量的值因为调用了方法,所以无法在编译时被确定下来
        final String s2 = "Hello " + String.valueOf(2018);
        
        System.out.println(s == "Hello 2018");
        System.out.println(s2 == "Hello 2018");
    }
}

结果:
true
false

分析:

  • Java会缓存曾经使用过的字符串直接量,例如执行String a = "java";后,在堆内存的字符串缓存池中就会缓存一个字符串"java",如果再执行String b = "java";,编译器会让b直接指向字符串池中的"java"字符串,因此a==b将返回true
  • s是一个"宏变量",它被替换为一个字符串直接量"Hello 2018",因此s == "Hello 2018"返回true
  • 由于s2没有被替换为一个直接量,因此s2 == "Hello 2018"返回false

再看几个示例来加深对final的理解 看如下代码:

public class Test {
    public static void main(String[] args) {
        
        String s1 = "HelloWorld";
        String s2 = "Hello" + "World";
        System.out.println(s1 == s2);
        
        String str1 = "Hello";
        String str2 = "World";
        String str3 = str1 + str2;
        System.out.println(s1 == str3);
    }
}

结果:
true
false

分析:

  • s1是一个字符串直接量"HelloWorld",String s1 = "HelloWorld";执行完后,编译器会把"HelloWorld"这个字符串加入到字符串缓存池中
  • s2的值是两个字符串进行连接运算,在编译阶段可以确定s2的值为"HelloWorld",所以编译器会让s2指向字符串池中的"HelloWorld"字符串
  • str3使用str1和str2做连接运算,所有在编译时无法确定str3的值,也就无法执行"宏替换"(就是把变量直接"变"为一个直接量),所以无法把str3指向字符串池中的"HelloWorld"字符串,所以s1 == str3返回false

为了让 s1 == str3 返回true,只要编译器对str1和str2两个变量执行"宏替换"即可,这样就可以在编译时确定str3的值,所以只要把str1和str2用final修饰即可:

public class Test {
    public static void main(String[] args) {
        
        String s1 = "HelloWorld";
        String s2 = "Hello" + "World";
        System.out.println(s1 == s2);
        
        final String str1 = "Hello";
        final String str2 = "World";
        String str3 = str1 + str2;
        System.out.println(s1 == str3);
    }
}

结果:
true
true

需要注意的是:只有在定义final变量时指定初始值才有可能触发"宏替换"的效果

对于实例变量而言,如果你在非静态代码块或构造方法中给final变量赋初始值,就不会有"宏替换"效果,对于类变量而言,如果你在静态代码块中给final变量赋初始值,也不会有"宏替换"的效果,以下为测试代码:

public class Test {
    
    final String str1;
    final String str2;
    final String str3 = "Hello";
    
    {
        str1 = "Hello";
    }
    
    public Test() {
        str2 = "Hello";
    }
    
    public void display() {
        System.out.println(str1 + str1 == "HelloHello");
        System.out.println(str2 + str2 == "HelloHello");
        System.out.println(str3 + str3 == "HelloHello");
    }
    
    public static void main(String[] args) {
        Test test = new Test();
        test.display();
    }
}

结果:
false
false
true
public class Test {
    
    final static String str1;
    final static String str2 = "Hello";
    
    static {
        str1 = "Hello";
    }
    
    public static void main(String[] args) {
        System.out.println(str1 + str1 == "HelloHello");
        System.out.println(str2 + str2 == "HelloHello");
    }
}

结果:
false
true

2. final修饰方法的功能

final修饰方法,用于限制该方法不能被它的子类重写,试图重写final修饰的方法编译就会报错

实际上,如果父类中某个方法使用了final修饰,那么这个方法就不能被子类访问到,因此这个方法也不可能被子类重写,从这个意义上说,同时使用private和final修饰方法没有意义,但这种语法是允许的,看如下测试代码:

而如果去掉@Override,表面上看是重写了父类的info()方法,实际上,Sub类的info()和Base的info(),没有任何关系,Sub中的info()是属于Sub自己的,独立的方法:

如果父类和子类没有在同一个包下,父类中定义的方法没有使用权限控制符修饰,那子类也无法重写该方法:

3. 为什么匿名内部类中要访问的局部变量必须使用final修饰?

看以下程序:

import java.util.Arrays;
import java.util.Random;

interface IntArrayProductor {
    int product();
}

public class Test {
    
    public int[] process(IntArrayProductor productor, int length) {
        int[] result = new int[length];
        for(int i = 0; i < length; i++) {
            result[i] = productor.product();
        }
        return result;
    }
    
    public static void main(String[] args) {
        
        Test test = new Test();
        final int bound = 10;
        int[] result = test.process(new IntArrayProductor() {
            @Override
            public int product() {
                return new Random().nextInt(bound);
            }
        }, 6);
        
        System.out.println(Arrays.toString(result));
    }
}

代码中的bound如果不用final修饰,编译就会报错,(在Java8之后,匿名内部类访问局部变量,该局部变量可以不用显式的用final修饰,因为java8之后,会默认给匿名内部类要访问的变量用final修饰),实际上,不仅是匿名内部类,即使是普通内部类,在其中访问局部变量,该局部变量都需要显式的或者隐式的(Java8之后)用final修饰

需要注意的是,我们说内部类访问局部变量,需要给该变量加final修饰符,这里的内部类指的是局部内部类(包括匿名内部类),因为只有局部内部类才可以访问局部变量,普通普通静态内部类和非静态内部类是不能访问方法体内的局部变量的

以下是普通的局部内部类访问局部变量的示例代码:

import java.util.Arrays;
import java.util.Random;

interface IntArrayProductor {
    int product();
}

public class Test {
    
    public int[] process(IntArrayProductor productor, int length) {
        int[] result = new int[length];
        for(int i = 0; i < length; i++) {
            result[i] = productor.product();
        }
        return result;
    }
    
    public static void main(String[] args) {
        Test test = new Test();
        final int bound = 10;
        class MyProductor implements IntArrayProductor {
            @Override
            public int product() {
                return new Random().nextInt(bound);
            }
        }
        int[] result = test.process(new MyProductor(), 6);
        System.out.println(Arrays.toString(result));
    }
}

那么,为什么匿名内部类中要访问的局部变量必须使用final修饰?要解释这个原因,首先需要了解两个概念:闭包(closure)和回调(call-back)

闭包是一种能被调用的对象,它保存了创建它的作用域信息,Java7没有显式的支持闭包,但对于非静态内部类而言,他不仅记录了其外部类的详细信息,还保留了一个创建非静态内部类对象的引用,并且可以直接调用外部类的private成员,因此可以把非静态内部类当场面向对象领域的闭包

通过这种仿闭包的非静态内部类,可以很方便的实现回调功能,回调就是某个方法一旦获得了内部类对象的引用后,就可以在合适的时候反过来去调用外部类实例的方法,简单的说,回调就是允许一个类通过其内部类引用来调用本身的方法

示例代码如下:

interface Teacher {
    void work();
}

class Programmer {
    
    private String name;
    
    public Programmer() {}
    
    public Programmer(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
    
    public void work() {
        System.out.println(name + "在认真敲代码...");
    }
}

假设有一个人既是"Teacher"又是"Programmer",那么要实现这样的一个类,需要实现Teacher接口并且继承Programmer类:

class TeacherProgrammer extends Programmer implements Teacher {
    
    public void work() {
        System.out.println(this.getName() + "在认真备课...");
    }
    
}

以上代码貌似没有任何问题,但是,其中的work()方法只能用来"认真备课",不能"敲代码",这时候,可以通过一个仿闭包的内部类来实现这个功能:

class TeacherProgrammer extends Programmer {
    
    public TeacherProgrammer() {}
    public TeacherProgrammer(String name) { super(name); }
    
    private void teach() {
        System.out.println(this.getName() + "在认真备课...");
    }
    
    private class Closure implements Teacher {
        // 非静态内部类回调外部类实现work()方法
        // 非静态内部类引用的作用仅仅是提供一个回调外部类的途径
        @Override
        public void work() {
            teach();
        }
    }
    
    public Teacher getCallbackReference() {
        return new Closure();
    }
    
}

public class Test {
    public static void main(String[] args) {
        TeacherProgrammer tp = new TeacherProgrammer("Tom");
        
        tp.work();  // 从Programmer类继承到的work()方法
        
        // 表面上调用的是Closure的work()方法,实际上调用的是TeacherProgrammer类的teach()方法
        tp.getCallbackReference().work();  
    }
}

结果:
Tom在认真敲代码...
Tom在认真备课...

非静态内部类对象可以很方便的回调其外部类的Field和方法,所以非静态内部类与"闭包"的功能是一样的

接下来继续解释为什么匿名内部类中要访问的局部变量必须使用final修饰

对于普通局部变量而言,它的作用域就是停留在方法内,当方法执行结束,该局部变量也随之消失,但内部类则可能产生"隐式的闭包",闭包使得局部变量脱离它所在的方法继续存在,以下为示例代码:

public class Test {
    public static void main(String[] args) {
        final String str = "Java";  // 定义一个局部变量
        new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0; i < 100; i++) {
                    System.out.println(str + " " + i);
                }
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();  // 运行到这里 main()方法就结束了
    }
}

正常情况下,当程序执行到.start();后,main()方法就执行完毕了,局部变量str的作用域也会随之结束,但实际上只要新线程里的run()方法没有执行完,匿名内部类的声明周期就没有结束,将一直可以访问str这个局部变量,这就是内部类扩大局部变量作用域的实例

由于内部类可能扩大局部变量的作用域,那么假如这个局部变量的值还可以被任意修改,那么将引起极大的混乱,因此,Java编译器要求,所有被内部类访问的局部变量必须使用final修饰符修饰

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏程序手艺人

C++之初始化列表

3066
来自专栏猿人谷

JDK1.7源码分析01-Collection

同步发布:http://www.yuanrengu.com/index.php/20180221.html Java的集合类主要由两个接口派生而出:Collec...

3365
来自专栏mathor

1小时掌握c++面向对象编程

使用对象指针实参仅将对象的地址值传递给形参,而不进行副本的拷贝,这样可以提高运行效率,减少时间开销

751
来自专栏java一日一条

浅谈Java中的equals和==

为什么第3行和第4行的输出结果不一样?==和equals方法之间的区别是什么?如果在初学Java的时候这个问题不弄清楚,就会导致自己在以后编写代码时出现一些低级...

582
来自专栏猿人谷

const用法小结

常类型是指使用类型修饰符const说明的类型,常类型的变量或对象的值是不能被更新的。因此,定义或说明常类型时必须进行初始化。 概述 1. const有什么...

1857
来自专栏编程理解

排序算法(一):冒泡排序

冒泡排序是一种通过交换元素位置实现的稳定排序方式,其特点是每一轮排序后,都会在首端或尾端产生一个已排序元素,就像水泡不断上浮一样,通过多次排序,最终所有元素变得...

1182
来自专栏微信公众号:Java团长

浅谈Java中的equals和==

  为什么第4行和第5行的输出结果不一样?==和equals方法之间的区别是什么?如果在初学Java的时候这个问题不弄清楚,就会导致自己在以后编写代码时出现一些...

891
来自专栏微信公众号:Java团长

浅谈Java中的equals和==

  为什么第4行和第5行的输出结果不一样?==和equals方法之间的区别是什么?如果在初学Java的时候这个问题不弄清楚,就会导致自己在以后编写代码时出现一些...

973
来自专栏V站

PHP常用系统内置函数,收藏以后别折磨自己写函数类了

1479
来自专栏测试开发架构之路

C和指针小结(C/C++程序设计)

C和指针 相关基础知识:内存的分配(谭浩强版) 1、整型变量的地址与浮点型/字符型变量的地址区别?(整型变量/浮点型变量的区别是什么) 2、int *p,指向整...

33711

扫码关注云+社区

领取腾讯云代金券