《Java中的变量和常量》源站链接,阅读体验更佳~
Java是一门’完全’面向对象的编程语言,它的所有的代码都是以类型声明为基本单元进行组织的,这是Java和C语言以及C++最典型的区别之一。
在C语言或者C++中,我们可以直接在文件的最外部声明函数和变量,把它们作为公共的声明,但是,在Java中这是行不通的,一个Java文件的基本结构如下:
package laomst.site;
impport ...;
public class A {}
class B {}
interface I{}
@interface A{}
enum E{}
第一行的代码是一个包声明,我们可以认为包名是一个名称空间,不过大多数的Java实现中包名和操作系统的文件系统路径名是一一对应的,它不光具有逻辑意义,还具有物理意义。当然包声明是可以被忽略的,这个时候这个文件中的所有类型声明都将会被放到一个缺省包中。个人不建议省略包声明。
下面则是一系列的import语句,表示我们引入了其他的类型声明,最后的则是我们该文件中声明的一系列类型了。而且所有的这些类型声明中,只有一个可以被标注成public,并且被标注成public的类型的名称就是该java文件的名称。
基于这些规则,我们在实际编写一个Java文件的时候,往往是一个类型声明独占一个文件,事实证明这也是一个最佳实践。
关于声明接口和类的语法,我们这里就不做过多的说明了,我们这篇文章中重点说明一下Java中的变量和变量的初始化。
变量
Java中的变量声明必须是包含在类型声明中的,而且Java中的变量不再和C语言以及C++中那样可以指定它们的存储方式,根据变量的不同声明上下文,变量的存储类型,初始化方式和生命周期都有不同的特性。
《Java语言规范》中说明了8种类型的变量及其生命周期:
-
类变量(静态变量)
它是在类声明中用static关键字或在接口声明中无论是否使用static关键字声明的域(在接口中声明的字段必定是public static final的)。
类变量是在类或接口的准备阶段创建的,并且被初始化为缺省值。类变量在类或接口被卸载时被有效地终止生命周期。
-
实例变量
它是在类声明中没有使用static关键字声明的域。
类或类的子类的对象被创建的时候,实例变量即被创建并初始化为缺省值。当包含实例变量的对象不再被引用时,并且该对象的所有必须终结操作执行完成后,该实例被有效地结束生命周期,同时该实例变量的生命周期也随之结束。
-
数组成员
它们是不具名变量
在新的数组被创建时,它们就被创建并被初始化为缺省值。数组成员在数组不再被引用时,即被有效地终止生命周期。
-
方法参数
它们对传递给方法的引元(实参)值进行命名。
在方法每次被调用时,都会创建新的参数变量。新的参数变量被初始化为方法调用中相应的引元值。当方法体执行完成后,方法参数即被有效地终止生命周期。
-
构造器参数
对于在构造器中声明的每一个参数,在实例创建表达式(在匿名内部类部分进行介绍)或显式的构造器调用对构造器的每次调用都会创建新的参数变量。
新的变量被初始化为创建表达式或构造器调用中相应的引元值。当构造器体执行完成后,构造器参数即被有效地终止生命周期。
-
lambda参数
它们对传递给lambda表达式体的引元值进行命名
-
异常参数
每当异常被try语句的catch子句捕获时,都会创建一个异常参数。
新变量会用与异常相关联的实际对象进行初始化。异常参数在于catch子句相关联的块执行完成后,即被有效地终止生命周期。
-
局部变量
在代码块中声明的变量,比如在for语句中,当控制流进入一个块或for语句时,就会立即为这个块或for语句中包含的局部变量声明语句中所声明的每个局部变量创建新变量。
当包含局部变量的块执行完成后,局部变量即被有效地终止生命周期。
上述的**方法参数、构造器参数、lambda参数、异常参数在广义上来说也都属于局部变量的范畴。**对于非局部变量(类变量和实例变量),它们的值是被存储在堆中的,对于局部变量,它们的值是被存储在栈中的,对于这一部分内容,本文就不进行详细说明了,后续文章中再填坑。
变量的明确赋值
在《Java中的表达式和语句》一文中我们曾说过,Java并不限制我们创建从未使用的变量,但是Java中变量必须遵循的一个规则是在真正使用一个变量之前(首次对一个变量进行RHS查询之前),该变量必须被明确初始化过。因此,在Java中了解变量的初始化是非常重要的。
对于非final的静态变量和实例变量,Java会自动将其初始化为缺省值,但是对于final类型的静态变量和实例变量,以及所有类型的局部变量,都要求我们手动进行初始化。
在《Java语言规范》中有专门的一章——‘明确赋值’就是讨论这部份内容的,明确赋值的核心思想就是在所有的从变量的声明到变量第一次被RHS查询的代码执行路径中,只要存在一条可能的路径使得在该变量被RHS查询的时候却没有被初始化,那么该变量就是没有被明确赋值的。
明确赋值这一点主要针对的是在语句体中声明的没有提供初始化器的局部变量和没有提供初始化器的final域(类变量或者实例变量),如下:
public class VTest() {
private static final String a; // 没有提供初始化器的静态变量,在使用之前必须明确赋值
private final String b; // 没有提供初始化器的实例变量,在使用之前必须明确赋值
public void foo() {
int a; //没有提供初始化器的局部变量,在使用之前必须进行明确赋值
}
}
静态变量和实例变量的自动初始化
我们上文在介绍变量的种类的时候提到过,非final的静态变量和非final的实例变量,以及数组的成员变量,如果我们在声明它们的时候不对它们进行显示的初始化,那么Java会帮我们把它们自动初始化为缺省值,而缺省值则是由变量的静态类型所决定的。各种数据类型的默认缺省值如下所示:
byte
缺省值是0,即(byte)0
的值short
缺省值是0,即(short)0
的值int
缺省值是0,即0
的值long
缺省值是0,即0L
的值float
缺省值是0,即0.0F
的值double
缺省值是0,即0.0
的值char
缺省值是空字符,即'0\u0000'
的值boolean
缺省值是false
- 对于所有引用类型,其缺省值为
null
对于非final的静态变量,除了自动初始化之外,我们也可以通过直接为其提供初始化器或者是在静态代码块中对其进行手动初始化。
对于非final的实例变量,我们也可以通过直接为其提供初始化器、在实例代码块中手动初始化或者是在构造器中对其进行初始化。
但是由于它们会被自动初始化,所以它们并不受明确赋值规则的约束。
数组成员的初始化
数组在Java中是一种比较特殊的类型,我们在声明数组变量的时候,可以为其提供初始化器:
int a[] = {1, 2, 3};
上面的代码会创建一个长度为3,元素类型为int的数组,它的每个元素会被初始化器中提供的值自动初始化。
当然我们在创建数组的时候,也可以只指定创建指定长度的数组:
int a[] = new int[5];
上面的代码在创建数组的时候并未提供初始化器,这个时候数组中的每个元素会被自动初始化为对应类型的缺省值。
关于数组,我们后面会有专门的文章进行介绍,这里就不做过多说明了。
局部变量的初始化
我们上面提到过,方法参数、构造器参数、lambda参数和异常参数都属于局部变量的范畴,对于方法参数、构造器参数和lambda参数而言,我们调用对应的可执行体的时候,都会给他们传递实际参数,这个时候实际参数就会把这些形式参数进行初始化;而对于异常参数而言,则会根据try语句中抛出的异常的类型来选择类型匹配的异常处理器进行调用,并会用try语句抛出的异常对异常参数进行初始化,关于异常参数,我们在后面介绍异常的时候会进行更加详细的说明。
而对于在语句体中声明的局部变量,则要求我们必须对其进行手动初始化,而且必须遵循明确赋值的原则。如下:
int a = 1;
int i;
...
if(xxx) {
i = 3;
}
int j = i; //Error
上面的代码中,对于变量a而言,在其作用域范围之内可以直接使用,因为我们在声明它的时候直接为其提供了初始化器,它已经是被明确赋值过的了。
但是对于变量i而言,if中的条件可能成立也可能不成立,而当其不成立的时候,在执行 int j = i
这行代码的时候,i就是没有被初始化过的,Java编译器会检测这些情况并给出相应的错误提示。
不可变左值和常量
上面我们已经介绍了Java中的变量,下面我们就来介绍一下常量。
常量应该具有以下两个基本的特征:
- 只读
- 值在编译时就是可以确定的。
乍一看,如果要满足上面的两个特征,似乎只有字面值才是真正意义上的常量。但是有时候我们只是需要有一些值是只读的,但是其值又是在运行时得出的,也就是说有时候我们可能需要一种’只读’的左值,对于这种只读的左值,在其整个生命周期中,我们只有一次机会对其进行赋值,之后就只能对其进行RHS查询,而不能在进行LHS查询了。
不可变左值——final变量
Java中使用关键字final来提供不可变左值的功能。但是在我们平常的讨论中,经常会把final变量称为常量,明显这是不准确的,但是往往也不会引起歧义。
声明为final的变量在其整个生命周期中,我们只有一次对其赋值的机会,即其被初始化赋值的时候,其他任何时候都不能再对其进行重新赋值,否则就会产生编译时错误。
Java中的final变量其实是一种’浅只读’,接触过复制的同学可能都知道什么是浅复制,那么什么是浅只读呢?比如在C语言和C++中,如果我们用const标识一个变量,那么这个变量在初始化之后将不能以任何形式出现在赋值操作的左侧,即使是我们想修改某个复合类型的某个成员变量,也是不被允许的。但是Java中的final变量只是限制我们不能对这个变量本身进行重新赋值,而对于变量内部的成员,则不进行限制。正是因为这一点,使得Java中编写值对象变得比较复杂,关于值对象,我们下文会有所介绍。
隐式的final变量
我们在声明任何一个变量的时候,都可以把它标注为final的,但是在某些上下文中,Java要求我们的变量必须是final的,这个时候,不管我们是否使用final来标识这个变量,这个变量都是final的,在Java中这样的上下文又三种:
-
接口的域
接口的域永远都是public的(在接口部分进行介绍),如果某个实现了该接口的类擅自更改了该接口的域,就会造成一种全局的影响,可能会带来一些问题。所以接口的域被自动标记为final的。
-
带资源的try语句中的资源参数
带资源的try语句实际上是一种语法糖,会自动插入finally语句,在其中进行资源关闭,如果我们在try语句中修改了资源的引用,那么在自动生成的finally语句中就找不到try中所声明的资源了,所以带资源的try语句的资源参数被自动标注为final的。
-
多-catch子句中的异常参数
多-catch子句指的是有能力捕获多个类型的异常的catch子句,形如:
catch (IOException | UnsupportOperationException ex){...}
在多-catch子句中的异常参数的编译时类型实际上已经丢失了,它可能是声明中出现的任何一个类型(这有点类似于TypeScript中的联合类型),而只有在运行时异常参数被初始化之后我们才能知道其类型,所以对于这种多catch子句中的异常参数,我们不能对其重新赋值。这本质上是由于Java是一门静态类型的语言所决定的。
只捕获一种类型的异常的catch语句的异常参数因为类型是明确的,我们可以对其重新赋值,所以它们不会被隐式的声明为final。
事实上的final变量
上面提到了,在某些特定的上下文中,变量必须被声明为final,同样的,在某些上下文中引用某个变量的时候要求被引用的变量必须是final的,Java中有两种这样的上下文:
- 在局部内部类中引用的局部变量必须是final的
- 在lambda表达式体中引用的局部变量必须是final的。
对于局部内部类和lambda表达式的这个限制,我们会在后面的文章中解释。
**对于局部变量,其具有如下的特征:局部变量的整个生命周期在编译时就是完全可以观测的。**针对局部变量的这个特点,Java在放宽了局部内部类和lambda表达式的上述两个限制——只要他们引用的局部变量是事实上的final变量就是可以了。
那什么是事实上的final变量呢?就是其本身并没有被声明为final的,但是在其整个生命周期中,只被赋值了一次。
常量表达式和常量变量
我们上文介绍过,字面值毫无疑问是符合只读以及编译期可知这两个特性的,是典型的常量。那么有没有别的语言元素也是符合这两个特征的呢?答案就是常量表达式和常量变量。
首先我们来介绍一下什么是常量表达式,在Java语言规范中给出了常量表达式的定义:常量表达式是表示简单类型或String对象的表达式,它只能由以下的部分构成:
- 简单类型的字面值或者是String类型的字面值
- 到简单类型的强制类型转换或者是到String类型的强制类型转换
- 一元操作符
+
(正号)、-
(负号)、~
(按位取反)和!
(逻辑非),、注意不包含++
(自增)和--
(自减)运算符 - 乘除操作符*、/和%
- 加减操作符+、-
- 移位操作符<<、>>和>>>
- 关系操作符<、<=、>和>=,但不包含instanceof
- 判等操作符==和!=
- 按位操作符&、^和|
- 条件与操作符&&和条件或操作符||
- 三员条件操作符?:
- 带括号的表达式,其中括号中的表达式必须是常量表达式
- 常量变量
上文是《Java语言规范(基于Java SE8)中文版》第15.28节对常量表达式的定义,我们先不管最后的常量变量,其实我们只要抓住其中的两个关键点就可以了:
- 常量表达式的类型必须是8大原始类型或者是String类型
- 常量表达式被一层层展开之后最终都是由字面值和运算符组成的
有了这两点之后,我们就不难发现,**常量表达式的值在编译期是可以计算的。因为字面量的值编译期是已知的,字面量之间的简单运算肯定是编译期就可以计算得出的。**如下是一些简单常量表达式的示例(注意以下的代码并不是合法的语句,是不能编译通过的):
"The integer" + Long.MAX_VALUE + "is mighty big."; // 一个String类型的常量表达式
3>=1||2<3; // 一个boolean类型的常量表达式
(short)(1*2*3*4*5); // 一个short类型的常量表达式,并且包含了向基本类型的类型转换
...
上面我们提到了常量变量,《java语言规范》也给出了常量变量的定义:用常量表达式初始化的简单类型或String类型的final变量,也就是说常量变量其实就代表了一个常量表达式。
一下是一些常量变量的示例:
class FinalTest {
static final String aString = "The integer" + Long.MAX_VALUE + "is mighty big."; //常量变量
static final boolean aBoolean = true; //常量变量
final short aShort = (short)(1*2*3*4*5); //常量变量
final double aDouble = 2.0 * Math.PI; //常量变量
public static foo() {
final int aInt = 123; //常量变量
}
}
需要特别注意的是,必须是声明后直接提供初始化器进行初始化的final变量才有可能是常量变量:
class FinalTest {
static String aString = "The integer" + Long.MAX_VALUE + "is mighty big.";; //这个不算常量变量,因为它表示final的
static final boolean aBoolean = true;
final short aShort; //这个不算常量变量,因为没有直接提供初始化器进行初始化
{
aShort = (short)(2 * 3 * 4 * 5);
}
}
因为常量变量必须用常量表达式初始化,并且是final的,所以在常量表达式中可以包含常量变量。
常量变量的宏编译(内联)
**通过上面的论述,我们可以知道,对于常量变量,其实际上代表了一个字面值常量,当我们在一个地方引用一个常量变量的时候,我们可以安全地使用常量变量所代表的值来替代这个常量变量,而且常量变量的值在编译时就是可以确定的,如果在编译的时候,我们就把被引用的常量变量用其所代表的值进行替代,那么就可以减少运行时内存访问和申请内存的次数,可以在一定程度上提高程序性能,Java中就进行了这样的处理,我们可以称之为宏编译或者是内联。**如下:
class FinalTest {
public static FinalTest a = new FinalTest();
public static final String bString = "abc";
public static final String aString = "123" + 1 + true + bString;
public FinalTest() {
System.out.println(aString);
}
public static void main(String[] args) {
}
}
运行上面的main方法,结果屏幕上输出123
,这里对a进行初始化的时候,会调用FinalTest的构造方法,在构造方法中会引用aString,而这个时候按运行顺序来说,aString并没有被初始化,应该输出null
才对,但是aString是一个常量变量,Java在编译的时候就用它代表的值123
来替代了对它的引用。
如果我们对上面的实例做出如下修改,则程序输出null
:
class FinalTest {
public static FinalTest a = new FinalTest();
public static final String bString;
static {
bString = "abc";
}
public static final String aString = "123" + 1 + true + bString;
public FinalTest() {
System.out.println(aString);
}
public static void main(String[] args) {
}
}
上面的代码中,由于bString不再是一个常量变量了,而aString中引用了bString,这就导致bString也不是一个常量变量了,这个时候Java编译器不会对aString进行内联,导致上面代码的输出结果是null。
以下是一个源码和编译后代码反编译的对比,也能证明常量变量被内联了,而且其中的aString是一个非static的常量变量,它同样也会被内联:
对于被宏编译的常量变量,其实在运行时已经不存在这个变量了,存在的只是其所代表的值,就跟字面量一样。
同时我们注意到上面的switch语句,要求内联常量变量的另一个原因就是switch语句。switch语句是唯一依赖于常量表达式的语句,即switch语句的每个case的标号必须是常量表达式,因为编译器需要确定每个case标号的值都是不同的,这就要求标号值编译期可知。
值对象
现在函数式编程越来越流行,Java8开始也对函数式编程提供了语法层面的支持,比如lambda表达式,同时还提供了stream类库。但是在Java中编写纯函数是比较困难的,究其原因就是值对象的编写比较复杂。(关于函数式编程的内容,我们后面会有专门的文章就行介绍。)
值对象具有以下的特征:值对象在初始化完成之后,其状态永远不会再发生变化,包括其状态的状态、状态的状态的状态…。Java中最典型的值对象就是String对象。
我们上文也提到过,由于Java中的final变量只是一种‘浅只读’,这就导致我们在Java中编写一个值对象是比较复杂的,进而就导致在Java中编写纯函数变得比较复杂。但是,这并不影响函数式编程思维在Java中的运用。
总结
这篇文章中,我们介绍了Java中的变量类型以及常量,还有常量变量的内联,当然对于类变量和实例变量的初始化过程介绍的并不详细,这会在后面介绍类和对象初始化的相关文章中填坑;同时对于数组成员的初始化,也会在专门的数组相关的文章中进行更详细的说明,比如多维数组的元素初始化等等。
以上就是我对Java中变量和常量的基本理解,感谢你耐心读完。本人深知技术水平和表达能力有限,如果文中有什么地方不合理或者你有其他不同的思考和看法,欢迎随时和我进行讨论(laomst@163.com)。