【Java】 重拾Java入门
【概论与基本语法】
取这个标题,还是感觉有些大言不惭。之前大三的时候自学过一些基本的java知识,大概到了能独立写一个GUI出来的水平把,不过后来随着有了其他目标,就把这块放下了。之后常年没有用,早就忘得精光。这次重拾Java,还是从最基本的看起。不过因为还保留着之前一点记忆以及在Python里获得的一些知识,可能写的是非常不完全的,很多东西我懂的话也就跳过了。
■ 第一话,命运之出会
绪论的绪论。。
Java是典型的OOP语言,即面向对象程序设计语言。编程语言一路发展而来,从机器语言,汇编语言走到了较为高级接近自然语言的现代编程语言。不过现代语言中也分成很多种,早期的语言解决问题时还是需要程序员把问题转化成计算机思考的模式,从来没有让他们真正地基于问题来解决问题。这主要是因为问题的模式多种多样,很少能够找到一种编程的组织方式来解决所有模式的问题。随着程序语言的发展,人们渐渐意识到了OOP可能是一个比较好的突破口。
在OOP的设计模式中,问题被更高度地抽象。OOP中有很多对象,每一个对象封装了一定的功能,可以视为是一个微型计算机,具有状态,还具有操作而用户可以要求它去执行这些操作。
从基本语言设定上来说,Java中也是一切都是对象。平时我们所说的变量,其实是对对象的一个引用。引用可以独立于对象而存在比如String s;。但是这样的引用没有约束给一个对象,所以是无法调用的。在声明引用的同时可以给这个引用约束一个现有或者新的对象比如String s = s1;或者String s = new String("Hello");。
■ Java的变量、对象、类、方法
每个语言都有一些内设的基本类型。基本类型越多,就代表着语言的聚合度越高。在Java中,有以下几种基本类型供使用者选择
boolean类,char类,byte类,short类,int类,long类,float类,double类,void。其中,从short到double的所有数值类型都带有正负号,几种整数类型的范围是short在±2**15,int在±2**31,long在±2**63左右。boolean类的取值是true和false(注意和python不一样首字不大写)。
基本类型针对对象而言,一个有类型的对象才是可以被解析的。而变量是对对象的引用。变量有作用域的概念,Java中的作用域可以通过看大括号包括的范围看出来。父作用域中的定义的变量可以在子作用域中使用,但是反过来不行。另外特别特别需要注意的是在Java中是不允许子作用域的变量覆盖父作用域的变量,或者同作用域内存在相同名字的变量。比如下面这段代码:
pulic class test{ public static void main(String args[]){ int i = 0; int k = 1; if (k == 1){ int i = 2;//报错,会报出变量名冲突的错误 } } }
类似的逻辑在C或者python中是语法正确的,在if语句块的作用域结束之后,下面的变量i的值将会变成2。Java的设计者认为,重复的变量命名会引起混乱所以不允许这么做。
值得一提的是,虽然引用会跟着作用域失效而失效,但是引用指向的对象不会自动地销毁。在C/C++等较底层的程序语言中,如果不释放已经没有用的对象就会导致内存空间浪费,最终变成内存泄露的问题。而Java和Python一样有一套垃圾回收系统,监视着所有new出来的对象,一旦确认对象没有用了就会把这个对象回收释放。
类是Java的核心概念,既然所有东西都是对象,那么类就是规定了一种对象具有什么特征和操作接口。类中可以定义成员变量(终于可以名正言顺地用这个词啦),成员变量即使不进行初始化也会有默认值。默认的boolean是false,所有数字都是相关精度的0或者0.0,char类型的默认值是null。但是这种设定并不适用于局部变量(比如在main函数中定义的变量)。事实上,这种默认赋值并不被鼓励,在进行变量声明的时候最好还是可以进行变量的初始化。
类中会有一些操作,以方法的形式体现。方法定义时格式是ReturnType methodName(args1,args2...){/*method body*/}。然后调用时应该使用objectName.methodName(args1,args2...)。args是参数,Java中定义方法时需要把接受参数的类型写清楚,否则会报错。如果规定了方法的返回值是void的话,那么就可以只写return;,否则编译器会强制要求返回一个具体的值比如return true;或者return 1.1;。
在一个文件中引用其他文件中的代码可以使用import关键字。import语句用来导入一个包(或者说一个类的库,其他语言中的包可能包含了一些常量或者函数等,但是Java中所有的代码都是写在类里的,所以导入的也就是一个类的集合)。在所有包中,java.lang以及相关的子包是不用写出来的,这个包将自动默认导入到所有Java文件中。其他的常用包,内建的包比如import java.util.*;。
另一个跟方法相关比较重要的关键字是static,被static修饰的成员变量或者方法将在类定义完成时就分配到存储空间(一般而言,Java类中的成员变量和方法都是动态分配存储空间的,即在new操作的时候才由Java为他们分配)并被固定下来。被固定下来之后,这些变量和方法(可以叫它们类变量/类方法或者静态变量/静态方法)就和类有没有实例化,实例化了几次没有关系。比如说下面这样一段代码:
public class test{ public static void main(String args[]){ MyTest t1 = new MyTest(); MyTest t2 = new MyTest(); } } class MyTest{ static int i = 47; }
对于MyTest类的两个实例,t1和t2都可以引用i。但是它们对i的引用指向同一个对象。同时静态变量或方法也可以直接通过类名来引用,MyTest.i引用到的对象和t1.i以及t2.i都是一样的。当通过这三个引用中任意一个改变对象的值的话,剩余两个也会反映出这种变化。这个和Python中的类变量有着相似和不同的地方。
● Java中的static和python中的类变量
所谓Python中的类变量是这样的:
class Test(object): i = 47 def __init__(self): pass
之前在Python的类机制的文章里面提到过,Python的类变量都是“自带static属性”的,而那些不静态的成员变量就应该写到__init__中以self.var的形式出现了。这其实还算是准确的。有怀疑的人可能会列出这样一段代码:
class Test(object): i = 47 def __init__(self): pass t1 = Test() t2 = Test() #print id(t1.i),id(t2.i),id(Test.i) t1.i += 1 print t1.i,t2.i,Test.i #结果是48,47,47
而在Java中:
class MyTest{ static int i = 47; } public class test{ public static void main(String args[]){ MyTest t1 = MyTest(); MyTest t2 = MyTest(); t1.i++; System.out.printf("%d %d %d", t1.i, t2.i, MyTest.i); //结果是48 48 48 } }
这个差异其实在python类机制中也讲到过好多次了,主要问题就出在python代码中t1.i += 1这句话,这句话看似和java中的t1.i++;意思一样,其实是t1.i = t1.i + 1,而赋值语句在Python中有声明变量的作用。所以这句话的意思是“给t1增加一个新的叫i的属性,这个属性等于t1.i(目前调用到的还是类变量)+1”,之后t1.i已经成为了一个成员变量了,而引用指向同一个对象的Test.i和t2.i自然没有被改变。
■ 分析一下HelloWorld程序
//test.java
//import java.util.*; public class test{ public static void main(String args[]){ System.out.println("Hello,World"); } }
上面这个是Java的HelloWorld程序。首先这个文件名是test.java,所以在这个文件中的唯一一个public 的 class名字一定得是test。(至于什么是public后面再说)其次,public的class里面一定要有main函数,main函数是程序的入口,一整个项目之中有且只有一个。Java编译器规定main函数一定要接受String args[]这样一个数组,表示是外界传递给程序的参数,类似python中的sys.argv。System是java.lang中的一个类,它含有一个静态属性,也是一个对象out,所以可以通过类名System来直接调用out。out这个对象还含有println这个方法,用于向stdout输出信息。
System这个类呢,像是python中的sys和os模块的合体,里面包含了很多系统和编译器相关的信息。比如System.getProperties();返回的就是跟java编译器以及系统相关的很多信息组成的“列表”(我不知道改怎么形容这个数据结构,以后学习得更多了应该会知道了吧。。)。再比如System.getProperty("user.name")可以获得当前系统的用户名,System.getProperty("java.library.path")可以获得java.library.path的信息。
*关于编译和运行:在windows上用IDE写这种小程序的话,直接点击IDE里面的运行就可以得到stdout的输出了。但是如果在linux上用vim写的程序呢,就要用javac命令编译,把.java文件编译成.class文件,然后再用java命令运行.class文件即可。
■ 关于操作符
操作符就不多说了,主要把几点和python中不太一样或者出乎意料的东西记录一下。
● 关于赋值语句操作引用
其实这个特性,java还是更加接近Python而非C语言一点。如下:
class Test{ int i = 47; } public class test{ public static void main(String args[]){ Test t1 = new Test();
Test t2 = new Test(); //习惯了python之后总是漏掉这句。。要先声明先声明先声明! t2 = t1; //此时t2是t1的一个所谓的浅复制 t1.i = 48; System.out.println(t2.i); //结果是48 } }
把t1赋值给t2,其实操作的是引用而不是对象本身,所以赋值之后,t1和t2引用的对象还是同一个,t1对i做出改变时t2的i自然也改变了。这有点像python中的深浅复制,比如字典A简单用等号赋值给B时,两者指向的对象仍然是一个,但如果是A.deepcopy()给B的话,那么B就是一个独立于A的副本了。
在Java,如果想要避免这个问题,可以把赋值语句写成t2.i = t1.i,这样得到的结果是t1.i和t2.i指向同一个对象,不过他们是int类型,所以指向同一个对象是无可厚非的(其实在Java中基本类型不能说是对象,因为JVM并没有开放基本类型对象的相关操作和读取接口,所以我们就认为这两个int就是相等了吧),而t1,t2两者不是同一个。
● 其他一些操作符说明
顺便说下,判断两个变量是不是引用了同一个对象可以用==操作符或者equals方法。而对于基本类型,没有equals这种方法的,只能用==来判等。相比较于python中的is关键字和==操作符,可以认为,java对于基本类型,==就是对数值的一个判等操作(反正不知道地址,无所谓is了。事实上Java的基本类型都隶属于Value类,而Value类是没有equals方法的),而对于非基本类型的变量而言,==和equals都是判引用是否相同。
关于逻辑操作符这里还要说一下,首先Java中的逻辑操作符是&&,||和!。三者只能操作与布尔类型的对象,也就是说没有python中那种 return A or B方便的形式了。然后逻辑操作运算返回的也是true或者false而不是某个被操作的对象的值了(在python中1 or 0返回的是1而不是True)。
三元操作符是?:,习惯了Python那样的if/else表示的三元操作符之后觉得确实这方面python要简单易懂很多。。需要注意的是Java中要求三元操作符的首个表达式一定要是返回boolean类型的,另外逻辑结构上三元操作符和if/else结构是一样的,不过会返回一个结果。
关于加号,Java中和Python一样允许用加号来对字符串做增长处理。对于"a"+"b"这种,当然是理所当然的。然而Java中更加自由的一点是可以允许字符串类型和非字符串类型的变量相加,编译时后者将会被强制转化成字符串。相当于"a" + b 就等于是 "a" + b.toString() 。另外还要注意,字符串的加和也是符合加法的基本规则的。比如str + ( int + int )的情况时,int和int之间的相加仍然是数字之间的相加,做完括号内加法后再和字符串相加。
■ 类型转换说明
类型转换也算是一种操作符,在Java中,做类型转换的格式是(cast) var。比如(String) 1,(int) '1'等。这里首先需要注意的是,Java中单双引号有区别了!单引号只能引起一个字符,是char类变量,而双引号引起多个字符,是String类型变量。其次,char类型变量可以经过(int)转换成int类型变量,但是和python的int("1")完全不是一回事。如果记性好的话说不定还记得大一是学的C语言时学过ASCII基本字符表,那里面每个字符都是有数字编号的,一共127个,这个字符转换是以这个为基础的!!比如(int) '1'得到的结果是49,(int) 'a' 得到的结果是97等等,总之要记住,又是一个和Python很大的不同。另外String类型比如"123"是没有办法直接转化成int类型的123的。
总之,强制进行类型转换的时候,Java不能像Python那样神通广大,只有在真的转换不过去的时候才报错。Java在类型层面上就规定了哪些可以转换成哪些类型。
另一方面,Java的类型转换分成两种,将信息更多的类型转换成信息更少的类型比如float转换成int,double转换成float等属于窄化转换,是危险的。当面临这种潜在的信息丢失的时候,编译器会强制我们进行显式地写出类型转换,否则报错,以提醒这种操作存在一定的危险性。比如float f = 0.5会报无法将double转化成float,要么加上(double)要么在0.5后面加上f(Java中的小数默认是double类型而非float)。当把信息少的类型转化成信息更多的类型的时候,叫做扩展转换,此时由于不造成信息的丢失,不必一定显式地写出类型转换过程。编译器也会自动帮我们做好转换。在各种类型中,boolean类型无法和其他类型互相转换,而我们自定义的类在一般情况下也无法和其他类互相转换。
● 转换过程中的小坑
1. 截尾和四舍五入
和包括Python在内的诸多语言一样,Java对把float或double等带小数的数字强制转化成int类型的时候,进行的工作是截尾。也就是说9.9会被处理成9而非10。如果想要四舍五入可以调用Math.round()方法。Math类现在已经包含在java.lang中,所以不必单独的import了。
2. 提升小类型到int
在对比int类型还要低的类型(short,char和byte)进行计算时,总是默认把这些类型的数据转换成int型再来计算,因此得出的计算结果必然是int及以上级别的。如果需要把计算结果赋回原来那样的类型的话就不得不面临一个信息丢失的风险了。实际上这是一个更大规律的一部分,即所有计算碰到类型不一样的类型时,往往需要进行类型转换,把较低的类型转换成较高的。因此最终的计算结果总是由整个算式中类型最高的数据来决定。
3. 没有sizeof了
熟悉C/C++的人可能需要对sizeof非常敏感,因为C语言的不同数据类型根据机器的位数不同也会不同,同一个程序要移植到不同环境中去的话就需要在程序中动态地控制一些东西。而Java没有这个烦恼,因为Java程序统一是在JVM上面运行,数据类型的长度是完全统一的。因此也就不需要sizeof这个函数了。
■ 流程控制的语法
因为Java也是用C语言实现的,所以其流程控制的语法还是和C语言很像的。大部分熟悉的语法就不再多讲了。下面讲几个感觉新鲜的
● foreach语法
以前一直以为Java的for循环只有for(i=1;i<=10;i++)的做法,想果然还是python方便。但其实现在Java也提供这种类似于for/in的循环方法了,格式是:
float f_array[] = new float[10]; Random ran = new Random(); for (int i=0;i<10;i++){ f_array[i] = ran.nextFloat(); } for (float f : f_array){ System.out.println(f_array[i]); } //可以打印出是个随机的小数
Java中的for (float f : f_array)相当于python中的for f in f_array,只不过
1.需要声明f的类型,相当于每一次迭代过程中先声明一个特定类型的变量,然后把后面那个“可迭代对象”的各个单元值赋值给这个变量来进行语句块中的运算。
2.只是进行赋值而不关联地址,在语句块中对f进行改变是无法将这种改变反映会数组的。比如把上面的for和foreach两种格式的for循环对调顺序的话看到的就是10个0.0了,因为初始化f_array时的值就是10个0.0。(一开始又想吐槽python真的好方便了,但是突然发现python中for i in lst对i做改变同样不会反馈到lst中去的【笑哭】,写了挺长时间python没有踩到这个坑,说明这不太会踩到嘛)
3. 第一条中的可迭代对象打了引号,因为Java中没有可迭代对象这种概念的,只能迭代各种形式的数组。比如对一个字符串迭代时不能直接for (char c : "hello")而是要调用一下String类的 toCharArray()方法。比如for (char c:"Hello".toCharArray() )。
另外如果需要while (True)这种无限循环的话可以写for(;;)也是可以被承认的。
● return语句
Java中return语句返回的东西应该和定义方法时定义好的返回值类型相一致。如果定义的返回值类型是void的话,那么可以不在方法体中写return,会自动在方法的结尾加上一个隐式的return;。不过不建议这么做。最好能够保证每一条语句分支都能有明确的return语句和值。
● switch-case语句
switch-case语句是多少次想python中有它了。。Java中的switch-case的语法是:
switch (integer-selector){ case integer-value1 : statement; break; case interger-value2 : statement; break; //... default : statement; }
正如之前学C时留下的映像一样,case子句中如果没有break则会按照顺序一路执行下去。直到遇到break或者default子句。
■ 类的初始化
Java的类的初始化方法也被称为构造器或构造方法,构造器必须和类名同名因此不遵循驼峰法则。如果类定义时不显式地定义构造方法的话,那么会默认该类可以使用一个无参构造方法。如果定义了一个构造方法,其可能有参或无参,那么这个类在实例化的时候一定要遵循定义的这个构造方法的节奏来走。方法有一个参数就一定要一个参数,两个就一定要两个。也可以在一个类中定义多个构造方法,这样就允许多种途径来创建类的实例。
构造方法是特殊的,因为它没有返回值。这个和返回值是void的方法还不一样,我们不需要也不能在构造方法中写return,同时在调用构造方法的时候会返回这个类的一个新对象的引用但是这个返回操作不需要我们在构造方法中写出来。同时,调用构造方法不需要显式指定任何对象,所以说所有构造方法也默认是静态方法。
● 关于方法重载
Java允许类中进行方法的重载。重载既可以针对父类中的同名方法,也可以针对本类中已经定义的同名方法。在重载方法的时候,只要保证规定的参数不同(个数、类型、顺序上的不同都可以。顺序的不同不建议这么做,会很难懂代码),那么就允许有同名的方法出现。在调用的时候只要注意参数该怎么写就可以了。Java的做法就是根据一个独一无二的参数列表来从一些同名的方法中找出一个特定的方法。也许有人觉得返回值的不同也可以作为分别同名方法的一种方式,但是并不是。因为在调用的时候,可能不把返回值赋予变量或干嘛,总之是不好的。
*在调用方法时,可能会遇到一种情况就是给的实际参数类型和方法定义时的形式参数类型不同,此时就涉及到了类型转换。正如前面所说的,扩展转换由Java自动执行,窄化转换Java不允许,会报错。
● this关键字
在被调用的方法中如果想要引用调用它的对象,那么可以使用this关键字,这个参数是由Java隐式地传递给构造方法的。this常常用于构造方法形参和成员变量重名的情况。比如class Person中有成员变量String name,int age,而构造方法又是Person(String name, int age)的话,那就可以在这个构造方法中写this.name = name,this.age = age。前面this.xx指代成员变量,后面的指代形式参数的值。另一种常用this的场景是当某个类的成员方法要求对调用它的类实例本身进行一些处理。
上面的两种调用this的方式都是讲this看成一个对象而言,如果在this后面加上了括号和参数,那么this就有了不同的含义,即代表了当前类的构造方法。比如我们可能在类中定义好几个构造方法而其中的一些可以调用另一些的操作。这样的话就可以直接在那些构造方法中直接用this(一定的参数)来调用其他构造方法了。
说到this在类中的应用,就不得不提static这个关键字了。在前面已经说明,被static修饰的变量和方法将具有静态的属性,即使在类没有被实例化的时候也在内存中占有空间。正是因为静态的属性可以不通过类的实例来调用,所以就不可以在static的方法中引用this关键字。事实上,static方法中不能直接引用任何非static的东西,除非通过某个实例对象的引用等办法来做,但是除了main方法之外,其他的静态方法如果需要这样来做的话显然有些多此一举了,你可以直接写一个引用的那个实例对象的类的成员方法,因为在一般的非静态方法中是允许调用静态变量和方法的。
有人认为静态方法和变量是不够“面向对象”的,当程序中出现了过多的static方法和变量时就应该反思是不是设计上不太合理。
● 成员变量初始化
正如之前所知,Java中对于类的成员变量的初始化是不要求必须的。也就是说成员变量是可以有默认初始值的。相比之下,在方法内部的局部变量必须要赋初值,否则会报错。如果成员变量不是基本类型,比如是自定义类型的变量而没有赋予初值的话,默认值是null。请注意,对于自定义类型而言,赋初值是指要让它=new xxx()这样子。Class object只是声明而已。比如说看下面这段代码:
public class test{ int i; test t; //如果这里换成test t = new test();那么下面的调用会报错,因为无限递归了。 void print_all() { System.out.println(this.i); System.out.println(this.t); } public static void main(String args[]) { test te = new test(); te.print_all(); } } //结果是0和null
另外如果是数组初始化比如int a[] = new int[10];,看起来似乎是无初值初始化,但是真的无初值的话应该就是int a[];此时变量a的值是null。而前一种new的时候数组规定的构造方法是给数组元素赋初值的,规律和类成员变量赋初值一样,这里就是10个0了。这里比较容易混淆的。类初始化的时候,对于所有类中非静态的成员变量,按从上到下的次序依次执行初始化。
● 成员方法和构造方法的初始化
成员方法先与构造方法初始化,后于成员变量初始化,且由上到下依次进行。也就是说,一个类无论其成员变量,成员方法和构造方法的次序是多么打乱的,总之是先第一遍从上到下扫描所有的成员变量并初始化第二遍从上到下扫描所有成员方法并初始化,然后再调用构造方法进行对象的初始化。而也是一直到对象被初始化为止,对象才在内存中有了相关存放的空间。
● 静态数据的初始化
static修饰的变量或者对象如果没有赋初值,那么基本类型默认是那些默认值,自定义类型默认是null。因为静态变量和方法在整个程序运行的过程中在内存中占有固定的一块地方,跟类是否进行了实例化无关,所以静态的数据的初始化必然是要早于非静态数据的初始化的(要是从头到尾都没有实例化的话,它们有可能甚至都不初始化)。具体的顺序应该是静态变量,静态方法(包括构造方法),非静态变量,非静态方法。这里可能有人会问,如果非静态变量处于静态方法中或者静态变量处于非静态方法中该怎么算。首先处于静态方法中的一定是静态变量,是有隐式的static修饰的,所以不存在前者。至于后者也不用担心,Java规定了静态变量只能存在于类的成员变量,而不能是方法中的局部变量。
因为静态数据的初始化总是早于非静态数据的,所以很自然的,静态方法中不能引用非静态的变量或方法。因为你调用这个静态方法时万一连一个实例都没创建,那非静态数据的具体取值该找谁要?
● 数组初始化
正如前面所说,数组的初始化可以通过类似于 int a[] = new int[10]; 或者 int[] a = new int[10];的方法得到被默认值初始化的数组。如果想要手动初始化也可以int a[] = {1,2,3,4,5}或者int a [] = new int[]{1,2,3,4,5},这里中括号中不能填具体长度且右边数据用大括号括起来。和C语言中类似,Java中的数组对象其实质是一个地址,所以在赋值如b = a之后,在数组b上做修改会反馈到数组a中。另外Java中的数组是个类,所有数组实例都隐式地含有length这个成员变量以告知用户这个数组的长度是多少。这个长度是自然长度,从1开始而不是index的从0开始计数。
这里再小介绍一下数组的一个方便的方法,是java.util中Arrays类的toString(一个数组对象)。和基本类型不同,数组类型的toString是要这样调用的。然后调用出来的结果就是像python列表那样表示的一个数组字符串了。
● 接受可变参数列表
有些方法,我们在定义的时候可能并不知道调用的时候会传进来多少的、怎样的参数。这时就可以用到可变参数列表。就像python中的def f(*args,**kwargs)一样,Java中的可变参数的表示是void f(Object... args)。Object在这里是基类,这里默认是接收一个基类的数组,因为Java中的所有类都是继承于基类的,所以所有类型的参数都可以被囊括进来。然后在方法体中,就可以像处理数组一样处理args这个参数了。比如下面这段代码:
public class test{ static void methodTest(Object... args){ for (Object obj : args){ System.out.println(obj); } return; } public static void main(String args[]){ methodTest(1,1.5,"Hello",'c'); } } /*结果就是: 1 1.5 Hello c */
这里还需要提下,跟python中的动态参数列表不同的是,Java中的这个动态参数还是可以指定类型的。上面用Object... args实际上是属于最大尺度地接受参数,即什么都行。如果写成Character... args或者int... args之类的话就可以只接收一种类型的参数了。另外,在动态参数前面加上不同类型的静态参数还可以重载出不同功能的同名方法。
● 枚举类型
首先枚举类型也是一种类型,声明的关键字是enum。因为是类型,所以遵从类的一些规则,比如public enum必须放在同名的.java文件中等等。一般enum类型中只要写上一些常量即可。比如下面这样
enum Degree{ NOT_CLASSFIED, LOW, AVERAGE, HIGH, DISASTER }
声明的时候,这些常量会按照顺序被赋予一些默认值。默认的赋值方案是从第一项是0开始向后逐个+1。在声明的同时,Java会自动帮助实现一些这个类的方法比如有static方法values(),针对每个常量的方法toString(),ordinal()等等。而使用枚举类型的方法如下:
public class test{ public static void main(String args[]){ Degree d = Degree.AVERAGE; System.out.println(d); //输出是AVERAGE,也就是说print的时候默认是字符串形式 for (Degree d : Degree.values()){ System.out.println(d+"="+d.ordinal()); //结果是打印出来了每个等级对应的真实值,也就是说明了values()返回的是常量对象的列表而对象.ordinal方法是返回了对象的真实值。 } } }
可以想到,其实enum类型和switch语句的配合肯定是很好的。
■ 垃圾清理与垃圾识别
首先应该明确,垃圾识别和垃圾清理是两个不同的概念,这一整个过程可以叫垃圾回收。垃圾识别是通过一定的方法从内存中找到没有用的对象即所谓的垃圾,垃圾清理是指把这些对象占据的内存空间释放。
在Java的类中,允许定义一个名为finalize()的方法,这个方法的作用是,当Java的垃圾回收器试图清理这个类的实例时,将首先调用实例.finalize()方法。这个过程中垃圾回收器并不立刻回收对象的内存,事实上最后对象可能甚至不被清理。会这么说主要是基于两点Java垃圾回收器的特点。
第一,垃圾回收器不会每时每刻都监听要回收什么东西,它只在一些特殊的情况下,比如内存接近满了或者其他什么情况下,由JVM层面调用它,开始识别需要回收的垃圾。这主要是因为有些程序比较小的话没必要总是进行垃圾回收,与其在辨别垃圾和进行回收上面花费资源不如就让它放在那里,反正程序结束之后所有资源都会被操作系统回收。
第二,找到垃圾之后,垃圾回收器也不是立刻进行清理,而也是等待一个合适的时机去释放。这个和第一条一样,由虚拟机来判断。
综合上面的垃圾回收器的特点来看,finailize()方法似乎非常重要。然而在Java中所有类的父类——基类中已经实现了这一方法,所以大多数情况下我们可能并不需要显式地重载这个方法来达到垃圾回收的目的——基类早就帮我们做好了这件事。有时候重载这个方法的目的是为了进行垃圾回收条件的验证,比如按书上说的,我定义了一个Book类,其中有一个checkedOut属性,从逻辑上来说只有当这个属性是false的时候这个对象才应该被销毁,那么我们就可以重载finailize方法,在其中对这个条件做一个判断,如果这个属性还是true,却被识别成垃圾要进行清理时就抛出一个异常,当然最后也别忘了要在后面调用一下super.finalize()来使得没有异常情况发生时对象确实得到销毁。此时的finailize方法就像是一个垃圾回收的回调点,当因为程序员的一些疏忽忘记把一些对象的checkedOut属性设置为false以标记其失效的话,在垃圾回收的时候就会抛出这个异常来提醒程序员这里有bug。
在程序中显式地调用System.gc()可以强制要求系统进行垃圾识别与回收。
以上其实主要讲了垃圾是在何种时机下以何种方法被清理的,那么垃圾又是如何被识别出来的?
首先是“引用计数”模式的内存管理技术,这种在python中被广泛应用的技术并没有被Java采用,因为1.循环引用的问题依然存在,而且相比于python,Java是个完全面向对象的,对象间的循环引用估计不在少数。2.因为Java要求的动态性没有python那么强,引用计数的动态管理内存优势不明显。
在Java中,首先用到的是“复制-删除”模式的技术。这是说在每隔一段时间后Java会查看内存中所有对象的有效情况(并不是只是查看引用计数,不同的JVM有不同的实现方式),把仍然有效的对象统一复制到另一片内存空间中,把无效的即需要进行清理的对象识别为垃圾。当垃圾数量远大于有效对象数量的时候,这么做显然是很有效率的。但是这样做的缺点是需要两片内存空间而且当程序渐渐稳定下来后垃圾没那么多了,此时再进行这种大规模的复制反而拉低了效率。因此Java也引进了和python一样的“标记-清除”模式的内存管理技术。具体标记-清除是什么样的这里不多说了。
值得一提的是,Java中两种模式的内存管理技术会在合适的时候由虚拟机自行切换以保证程序运行整体是处于高效率状态的。这也给了Java内存管理方面“自适应”的特点。而且在复制-删除时,复制到新内存空间中的有效对象们会一个挨一个地排列,这还提高了访问的效率。综合下来,Java的内存访问和分配速度也不逊色于C++这些语言。
【访问权限控制】
Java是面向对象特征很强的语言,类是其核心。就想Python中的命名空间一样,Java对类以及类中的成员变量或者方法进行了访问控制。访问权限在变量、方法和类定义时就应该给出,表现形式是在声明时加入权限修饰符public,protected,(默认,即什么都不加),private四种。下面来详细看一下权限访问控制。
■ 包
在正式接触权限访问控制前应该需要了解包是什么。从逻辑结构的层面上来看,包就是一群类的集合。正如前面说过的java.util就是一个包,而如果要用到包中的类就可以用import 语句来导入。和Python的规则类似的,*代表一个包中所有的类。所以我们可以import java.util.*;之后,包中所有类就可以直呼其名了。(当然就java.util这个包而言没必要这么做,每个Java文件都隐含这句语句了)
在说明包之前,首先来说明一下Java中几种类型的文件。.java文件是源码文件,一个.java文件也被称为一个编译单元,里面可以写若干个类,但只能有一个public修饰的类并且这个类要和文件同名。当在进行java文件的编译的时候,JVM会对每一个类生成一个.class文件。所以即使编译的java文件很少,也是可以得到很多class文件的。和Python这种动态解释型的语言(完全动态解释)以及C这种完全编译型的语言(源码编译成中间文件,链接器利用中间文件生成可执行文件)都不同,Java程序的运行机制是这样的:源码被编译成class文件,将一组可以打包的class文件打包并压缩成一个jar包,jar包对于Java来说就是一个可执行文件,Java的解释器负责在jar包寻找,装载并解释执行相关的类。
● 包的声明和引用
对于java文件来说,有一些文件功能相近,可以归类到一起,就可以用到包这个概念。包就相当于python中的模块,反映在文件系统中就是CLASSPATH下一个子(或者孙,玄孙等后辈目录。。)目录(而且不用像python那么麻烦的创建一个__init__.py文件),它的唯一要求就是包中的每个源文件的第一句话,必须是package XXX(该文件所处目录与CLASSPATH的相对位置关系),借此声明这是个包并且此文件隶属于这个包。从创建者的角度来说,包可以帮助更好地管理功能各异的代码;而从使用者的角度来说,包的最大意义可能在于命名空间上的区别。比如两个包下有同名的两个类,如果没有包名作为区别,我们要使用一个类时解释器就不知道具体指哪个类。但是有了包名我们可以很清楚地import packageA.class和import packageB.class来引用这个包以及包中的类。
在一个文件的第一行声明包的时候,如果文件所在包不是CLASSPATH下的一级子目录,就记得也要把路径写完整,不能只写当前最低的那级目录名字。
为了区别于Python这里还要讲到的一点是,Java中的import只能import类,不能import一整个包或者import类中的一个静态方法(普通的成员方法就更不要说了)。如果单独想要导入一个静态方法,不想在前面写上类名来引用的话可以考虑这样的关键字:import static。import static some.package.class.staticMethod之后,staticMethod就可以像是系统自带的一些方法一样直接调用了。
● 通过CLASSPATH来找类
Java程序在正式运行时就是以环境变量CLASSPATH中的各个目录作为当前目录,把引用者文件中的import语句后路径中的小圆点换成系统分隔符,以相对路径的方式来寻找各个类的具体class文件的。其实从上面的描述就可以看出,Java对于包的设置和引用是需要双重认证的。即被引用包本身要写正确的package声明语句。如果package后面的路径和真实的,相对CLASSPATH的路径不符的话会报“预期包名和XXX不符”的错;另一方面,引用方的文件里当然也要写上正确的import语句才能正确地引用到目标包中的一些类。在实际应用过程中,可能很多情况下磁盘上存在的不class文件而是jar包,此时就应该注意CLASSPATH中应该写到.jar文件位置而不是写到其所在的目录。解释器会自动解析出jar包中的类以供引用者引用。
关于Java和Python在import机制上的一个区别就是,python在import的时候搜索的是sys.path,这是一个由python解释器自己维护的“环境变量”。而Java运行时搜索class文件的路径由系统的环境变量CLASSPATH决定。所以java中不存在什么相对导入,我要使用别的类时,不论身处何处,只要是在这个系统中,可以解析CLASSPATH这个系统变量的话,就一定得和其他所有java文件一样进行类的导入工作。比如teacher.java和student.java两个文件都处在%CLASSPATH%/school包中。如果想要在teacher.java中import student.java中的类,Python中的话因为他们在同一个目录下,可以直接引用,但是Java中必须写import school.student.*;才可以。这是特别需要注意的。
● 用*时的困惑
刚才说了对于引用者而言,包可以区分同名类。但那个前提条件是用完整路径来应用。比如import school.student.thirdGrade和school.teacher.thirdGrade之后,使用时得呼呼啦啦这一大串全写上。如果是import school.student.*和school.teacher.*的话,使用thirdGrade这个类时就无法区分它到底来自哪个类了。所以执行的时候会报错。
和python中类似的,除非确定没有重名冲突,可以使用*符号来批量地导入一批类,否则宁可麻烦一点写全名吧。
● 自己造个小轮子
在掌握了以上对包的认识之后,下面我们可以自己来写个小轮子。比如按照书上那样,每次向stdout输出时要调用System.out.println()太麻烦了,可以把它包装成一个更加简洁的print方法。具体做法就是比如说在CLASSPATH的某个目录下建立一个我们自己的包,然后在里面建立文件:
package franknihao.tools; public class mytools{ public static void print(Object obj) { System.out.println(obj); } public static void print() { //因为参数不同,所以是两个不同的方法 System.out.println(); } public static void print(Object...objects) { //接收多个参数,然后把它们用逗号隔开地打印出来 for (int i=0;i<objects.length;i++) { if (i != 0) { System.out.print(','); } System.out.print(objects[i].toString()); } } public static void printf(String format,Object...objects) { System.out.printf(format, objects); } }
之后在其他的文件中,我们可以import static这些方法,然后在程序中就可以方便地用print方法啦。比如:
package mytest; import static franknihao.tools.mytools.*; public class test2{ public static void main(String args[]) { print("Hello,World"); print(); //输出一空行 print(1,1.5,'c',"hello"); } } /*输出结果 Hello,World 1,1.5,c,hello */
■ 访问权限控制
经过上面对包的讲解之后,我们肯定会想到一些权限方面的问题。比如说刚才造的小轮子,是不是在任何地方的任何java文件都可以import这些方法呢?如果不想这么做该如何做出限制?其实做法非常简单,就是public,protected,缺省和private这几个权限修饰符。权限修饰符加在类的成员变量、成员方法以及类本身前面,用于指示这个变量/方法/类可以被哪些文件访问。访问这个词比较模糊,对于类而言,就是说能不能import并且new个对象,对于方法而言可能是说可不可以通过方法所在类new出来的对象调用(可能这个对象能调用一部分方法而不能调用另一部分无访问权限的方法),对于变量而言,也是和方法类似的。
下面我们将逐个进行分析,说明每个权限的具体权限范围有多大。
● 缺省权限
缺省权限又称包访问权限(下简称包权限),在上面的一些编码中没有具体指出权限的内容都是包权限等级的。一个编译单元(即一个.java源码文件)只能隶属于一个包,包权限就是说当前包中的所有文件,如果有需要都可以访问相关内容。这里所说的包中所有文件是指和被引用文件同级别目录下的文件,不包括包的子包中的文件。
缺省权限的另外一个特点就是不需要import。比如一个包中的文件A.java中有public class A。那么在同一个包中的B.java或其他任何文件中都可以直接在代码中引用A obj = new A()而不需要import package.A。因为他们是缺省权限的。
缺省权限还有一种设定,即默认包。假如在同一个目录下创建两个.java文件,但都没有声明package xxx。也就是说在java解释器看来两者并不属于任何一个包。此时看起来两者好不相干,但是实际上它们却可以互相引用对方缺省权限的东西。这是因为Java解释器在碰到同目录下没有声明包名的.java文件时会创建一个默认包并把它们包含在其中。
● public权限
public是最高一级权限,意味着所有人都是可以访问相关内容的。一般来说.java文件肯定有一个pubilc class而且和文件名同名,语法上也允许一个public class都没有的情况,此时文件名可以任意取。
● private权限
private权限只能用于成员变量和成员方法,因为它的意思是被修饰的成员只能在类中被访问。即使是处于同一个包中,同一个文件中的其他类也无法访问这个成员(但是不意味着不能改变它的值,比如可以留出setVar和getVar两个方法接口,来实现安全地读写成员变量)。一般来说,包权限已经提供了足够的隐藏措施。因为你的程序发布时必然是携带了完整的包结构的,而外部调用你的程序的程序员其编程的范围已经在你的包外面了。所以一般考虑就是哪些内容是需要开放给外部程序员的,可以声明成public,其他的声明成缺省或者private。
● protected权限
protected也是一个不属于类,只属于变量和方法的权限。其含义是被修饰的方法或变量只能在包内以及当前类的所有子类实现中才能访问。因为重点强调了子类,所以protected权限也被称为继承访问权限。这里说的所有子类是包括非本包内的子类的,所以protected权限有点像是一个缺省权限加上阉割版的public权限后的产物。
● 小总结
上面介绍的四种访问权限,从开放到封闭的顺序来看依次是public,protected,缺省,private。其中成员变量和成员方法可以用四种全部修饰符,类只能使用public和缺省两种。权限的主要作用,从设计模式上来说主要是为了“封装具体实现”。正如上面所说的,把一些过程和变量通过private等低级权限修饰符封装在类当中,然后留出高级权限符修饰的一些接口供外部调用。如此就可以做到封装起来一个类的功能,这样既安全又方便。
权限总是以更高一级的元素的权限为准。比如一个类是缺省权限的,尽管里面有public的方法或者变量,但是另一个包的文件没法访问它们,毕竟你连这个类本身都访问不了的话就没什么意义了。但是public的static的方法却是一个例外,包外调用者虽然无法创建包内类的对象,但是却可以通过类名来调用包内类中的静态方法。说白了,权限控制的,是对创建类对象,以及通过类对象进行类内变量和方法的调用。
因为类只能有public和缺省两种权限,如果需要创建一个类但是又不想让别人进行访问的时候,可以将类中的相关成员变量和成员方法都修饰成private。你可能会说这有什么意义。考虑这样一种情况,我创建的类并不想让外部调用者可以自由通过类的构造方法来创建这个类的实例,而是通过我指定的一些方法来进行,这些方法里面可以对创建行为做一些控制或者统计,比如想统计总共创建了多少次这个类的实例,那么就可以通过在这个static方法中埋点来实现。这样考虑的话,可以在public的class中写一个private的构造方法,然后再写一个public static的方法来返回一个该类的对象。让外部调用者调用static方法。比如下面这样
public class test{ public static void main(String args[]){ //Soup soup = new Soup();这是会报错的 Soup soup = Soup.makeSoup(); //不要忘了前面的Soup来声明soup是一个Soup类的变量。 } } class Soup{ private Soup(){} pubic static Soup makeSoup(){ Soup soup = new Soup(); //类内可以调用private的构造方法 return soup; } }
■ 类的继承
Java作为经典的OOP语言,必然是有强大的类继承机制的。声明一个类时如果不显式地声明该类继承于哪个类的话就默认是隐式地继承Java中的Object类。Java中采用关键字extends来表明某个类继承自其父类。如下面这段代码:
class Cleaner{ private String s = "Cleaner"; public void append(String a) {s+=a;} public String toString() {return s;} public static void main(String args[]){ Cleaner x = new Cleaner(); x.append("hello"); print(x); } } public class Detergent extends Cleaner{ public void append(String a) {s-=a;} //改变一个方法 public void foam(){ print("in method foam"); } public static void main(String args[]){ Detergent x = new Detergent(); x.append("Hello"); x.foam(); } }
从上面这段代码中可以看出几个比较出人意料的地方。首先应该明确的是类的继承之最大意义在于对原类中方法的重载和补充,所以Detergent这个类对append方法进行了改造,并且补充了foam方法。另外还可以注意的一点是,所有类都是继承于基类的,而基类中有个toString方法很有用。在实现了这个方法之后,这个类的实例就可以直接和字符串类型相加减了。然后就是两个类中各有一个main方法。
以前我一直以为一个Java项目中最多就只能有一个main方法。其实不然。main方法只不过是指出了整个项目代码的入口,而一个项目可以有多个入口。当在命令行中我们编译某个类,就是寻找了这个类的main方法作为代码的入口。在每个类中都写上main方法,并且在main方法中写上相应的类的一些验证,这样这种模式就可以拿来做对类的单元测试。因为编译完成之后,即便是处于同一个java文件中的类还是会分出不同的class文件来让我们运行。
以上例子涉及到了子类对父类方法的重载和补充,那么如果调用呢?如果是通过子类的实例去调用父类的方法只要是有访问权限,那么都是可以调用起来的。如果是在子类定义中,那么只要是有访问权限就可以直接用方法的名字调用而不用类似于super.method()这样来调用。
再来考虑一下继承时权限控制的问题。如果原先父类中的成员变量或方法本来就对继承者开放那没什么可说的,但是如果原本是不开放的,比如包外的类继承本类后想用其中的缺省权限成员,这还是不允许的。因为不知道要继承我们这个类的类会在哪里,所以一般来说都会把成员变量写成private而把成员方法作为借口写成public。当然这不是绝对的,但是这种设计模式是很有启发意义的,符合OOP的初衷,即把私用的方法封装在类内,即便是子类想要继承也不让。
● 父类的自动初始化
从外部看来,子类似乎和父类的联系是部分相同的接口以及子类当中可能会有新增的成员变量和方法。但是实际上,两者之间还有一个更加紧密的联系,涉及到双方构造方法。即,子类在调用(无参默认)构造方法创造子类 的对象时,会自动调用父类的构造方法来为子类对象创造一个父类对象作为子对象,通过关键字super可以调用这个子对象。直白点说,就是每次调用子类的默认构造方法时都会依级自动调用所有长辈类的方法。并且这个调用是在子类构造方法的所有代码执行之前的位置被调用的。可以看下面这个例子:
class Creature{ Creature(){print("creature method");} } class Human extends Creature{ Human(){print("human method");} } public class Frank extends Human{ public Frank(){print("Frank method");} public static void main(String args[]){ Frank f = new Frank(); } } /*结果 creature method human method Frank method */
以上碰到的三个构造方法都是没有参数的,试想如果父类中的构造方法全是要求有参数的,这就会导致子类在继承的时候无法自动调用父类的构造方法,因为你不知道要调用哪个,就算知道也不知道传递的参数应该是多少。此时编译器会报错,需要程序员自己来显式地调用super方法来指明具体调用父类构造方法。显式地调用super方法时一定要注意将super方法写在子类构造方法的第一句。另外super在类的方法中的一般含义是父类的一个匿名的实例对象。所以在合适的地方也可以调用super.method()来在子类代码中指定地调用父类中的某个方法。
● 代理
继承常常用在子类需要用到父类方法的场合中。但是如果子类只需要用到一部分父类开放的方法而对另一部分不需要的话,直接继承整个父类明显会带来不是很合理的现象。比如子类的调用者可以直接调用父类的方法,而这种越级越权的调用时Java要努力避免的。一种比较好用的设计模式就是所谓的代理。基本上是指下面这样:
class planes{ public void up(){print("up");} public void down(){print("down");} public void shoot(){print("dadada");} } class airbus extends planes{ //.... //这样直接继承势必会导致客机类继承了shoot这种看起来是为战斗机准备的方法 } class airbus2{ private planes planeContorl = new planes(); pubilc void up(){ planeControl.up(); } public void down(){ planeControl.down(); } } //这样通过一个private成员变量做代理,就可以实现“子类”只使用“父类”的一部分方法了。其实这不算是继承,儿算是一种设计模式把
● @Override
这个看似像极了装饰器的东西是Java的姑且叫注解的一种语法。Override注解主要和子类重载父类方法有关。前面我们说过,对于子类继承父类之后,在子类中可以写一些和父类中同名的方法。如果参数一致,那么就认为是重写了父类的这个方法(重新实现,父类中方法的逻辑被覆盖),另外由于Java判定两个方法相同不仅仅看方法名还看方法的参数列表,所以如果参数不一致,那么就认为是子类中新增加了一个新方法。
这就隐藏了一个小bug,如果我们确实想重写方法,但是一不小心手抖写错了参数列表,或者一手抖打错了方法名,这样编译器就以为我们是想给子类增加新方法。为了杜绝这种错误,我们可以在子类重写方法前面加上@Override这个注释。加上这个注解之后,编译器在编译时就会自动帮助我们检查,如果在父类中没有找到名字和参数都相同的方法就会提醒我们这里有错,我们期望重写一个方法但是变成了新增一个方法。
■ 自定义类的向上转型
我们知道子类的对象是可以调用继承自父类的方法和变量的。那么在某种场合下(比如一个方法规定的参数是父类,但是传递进一个子类实例作为参数的时候),我们怎么知道此时调用的到底是子类实例还是说java会把这个子类实例转化成父类实例呢?这个涉及到向上转型的问题。在基本类型比如int,float的场合下,这个是可以明确判断出来的,但是自定义类型涉及成员的调用使得这个判断变复杂了。对于这个问题我们可以这么说:
java对类型的检查是严格的,也就是说,定义时方法接受父类对象作为参数,那么即使传递的是子类实例,我也是要把它转化成父类再执行方法的。而这个转化过程其实就是向上转型。也就是说,对自定义类型的处理是和一般类型类似的。
我们看一个例子:
public class test1{ public static void main(String args[]) { Child c = new Child(3); c.printChildVar(); } } class Parent{ protected int var; public Parent(int num) { var = num; } protected void printVar(Parent p) { print(p.var); } } class Child extends Parent{public Child(int num) { super(num); var = num + 1; } public void printChildVar() { super.printVar(this); } } //结果是4不是3
从上面这个代码中甚至还可以看出所谓的向上转型具体的操作,其实就是提取了子类中无论如何都会存在的那个父类实例子对象。否则打印出来的就可能是子类中的同名变量var的值4了。(这里参数是Parent p的方法访问的是成员变量,如果访问的是成员方法,有一些不同。具体写在后面那篇文章的开头了。)
说完自定义类的向上转型,还想指出的一点就是,虽然上面说了很多继承相关的内容,但是需要注意的是,只有在确定需要“继承”这种逻辑的情况下建议用它外,其他时间还是应该尽量用代理的方式(即声明一个子变量是那个类的实例)。而所谓确认要用继承,大多时候就是指可能我们需要进行向上转型的时候。
■ final关键字
final关键字可以修饰数据、方法或类。
● 修饰数据时
可以看成是分成两种情况,一种情况是修饰基本类型的数据时,final表示修饰的这个变量其值不可以被改变,如果试图改变其值就会报错。由于其不会改变值这个特点,通常可以将其应用在编译时常量这种角色上。
需要注意的是值不可被改变不代表只被初始化一次,final和static两个关键字之间互相独立,可以连起来用比如static final。对于static final的变量,表名这个变量在初始化时有值并且不可被改变并且只初始化这么一次。这就说明程序从头到尾这个变量就定死在那里了。所以通常把static final类型的变量名全大写,来表示这是一个类似于“宏”或者编译时常量的这种概念。为了让它能够被应用到全局,通常也会赋予其public权限所以最终往往都是以public static final的形式出现的。
第二种情况,如果final出现在一个自定义类型或者复杂类型比如数组这样的前面时,表示该变量到对象的引用是不可改变的。但是对象本身的值是可以改变的。也就是说(用Python一点的代码来说)final ls = [1,2,3]的话,不能ls = [1,2,3,4]因为这改变了引用,但是可以ls.append(4)因为这是直接改变对象的值。
● 修饰方法时
final关键字还可以用于修饰方法,当修饰方法时,最首先的考虑就是让继承本类的子类无法重写final的方法,也就是说这个方法在本类和本类以下的所有子类都保持完全一致。从逻辑上来说和@override是相反的,后者是保证我们在继承时一定要重写某个方法。
此外,final方法的调用还和普通的方法略有不同。普通方法在调用的时候,编译器会把自变量压入堆栈,跳到方法代码块进行执行,然后返回堆栈释放变量,最后处理返回值。如果是final方法,那么编译器可以直接把方法代码块复制一个副本到调用者的空间中直接执行,省去了跳来跳去的时间。但是如果方法很大很慢那么节省的这部分时间并不会很明显。
所有的private方法都自动是final方法。因为这个方法无法被外界访问,更不用说继承重写了,在代码中也可以为private方法加上final修饰不过不会有任何显式效果。
● 修饰类时
final类的意义是这个类不能被继承。需要注意的是,final修饰了类之后所作的仅仅是禁止了继承而不禁止其他比如访问变量和方法等动作。final的类中的方法必然是final的,因为类禁止其他子类继承,自然就没有重载方法一说了。
■ 初始化和类的加载(感觉越写越杂了。。)
在传统编程语言中,程序作为一个整体一次性加载进内存,然后依次开始变量的初始化。如果一个static的内容要用到另一个还未初始化的static元素就会非常僵硬。但是Java的分块加载机制避免了这种尴尬。Java中的每个类都会被单独编译成一个文件,类的代码只有在初次被使用时才被加载,这个初次使用对于一般的类而言就是第一次创建类的对象的时候。而对于static的内容,则是第一次访问static内容时也会加载相关类。进一步来看,所有的构造方法都是默认static的,所以总的来说,只要类中的static成员被访问,这个类文件就会被加载。
书上下面的这段代码可以比较好地来说明static内容在加载时的特殊性
class Insect{ private int i = 9; protected int j; //没有初始化,基本类型默认初值为0 Insect(){ print("i = "+i+";j = "+j); j = 39; } private static int x1 = printInit("static Insect.x1 initialized"); static int printInit(String s){ print(s); return 47; } } public class Beetle extends Insect{ private int k = printInit("Beetle.k initialized"); public Beetle(){ print("k = "+k); print("j = "+j); } private static int x2 = printInit("static Beetle.x2 initialized"); public static void main(String args[]){ print("Beetle constructor"); Beetle b = new Beetle(); } /*output static Insect.x1 initialized static Beetle.x2 initialized Beetle constructor i = 9,j = 0 Beetle.k initialized k = 47 j = 39 }
为什么结果是这样的?首先编译器完成所有类的编译之后首先要访问main函数,因为main函数是static的,所以要先加载Beetle类。但是又注意到Beetle类有父类Insect,所以先加载Insect。(这步向上加载的步骤是我们不可控制,程序自动执行的。且如果Insect还有父类则会一直向上追溯)。开始加载Insect类,注意到第一个加载的static内容是Insect的构造方法,但是这里是加载而不是执行所以Insect方法被加载进内存但是不会print出任何东西。
接下来按照顺序加载到x1,因为它也是个static,所以会初始化,初始化又用到了另一个static方法,也初始化,所以打印出来第一条结果是那样的。此时Insect类的加载已经完成,接下来可回头继续加载Beetle类了。同理x2的初始化引起了第二条结果。至此所有代码加载完成,开始执行。首先第三条结果在构造方法被执行之前打印出来。进入构造方法后,因为子类构造方法执行前隐式地执行其父类的构造方法所以第四行出现。接着进入了Beetle构造方法,由于变量k不是一个static的变量,在用到它时才开始初始化,所以第五行是在用到k的时候k初始化时出现的,第六七两行自然就是构造方法的产物了。还有一点j=39是因为子类中没有声明变量j,所以调用j默认通过其父类子对象进行。而父类子对象在初始化的时候构造方法里对其成员变量j已经做过调整,导致子类访问到的也是变动过后的j。
到此为止把前六七章简单过了一遍。。写得太长了,换一篇另起开头继续。。