详解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修饰符修饰

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,047评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,807评论 3 386
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,501评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,839评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,951评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,117评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,188评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,929评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,372评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,679评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,837评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,536评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,168评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,886评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,129评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,665评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,739评论 2 351

推荐阅读更多精彩内容