导图社区 Java手册
计算机专业同学福利!总结了6年的JavaScript 高级程序设计思维导图,非常全面非常细致。需要拿去!Java具有简单性、面向对象、分布式、健壮性、安全性、平台独立与可移植性、多线程、动态性等特点。Java可以编写桌面应用程序、Web应用程序、分布式系统和嵌入式系统应用程序等。还不快收藏学起来!
编辑于2020-05-28 21:33:27本资源名为“CPlusPlus编程语言基础”,由袁宵归纳整理出五千余条C 知识点,几乎涵盖了C 基础的所有知识,希望此资源能帮助C 初学者入门和C 使用者参考。
计算机专业同学福利!总结了6年的JavaScript 高级程序设计思维导图,非常全面非常细致。需要拿去!Java具有简单性、面向对象、分布式、健壮性、安全性、平台独立与可移植性、多线程、动态性等特点。Java可以编写桌面应用程序、Web应用程序、分布式系统和嵌入式系统应用程序等。还不快收藏学起来!
本资源名为“CPlusPlus入门”,系统性地介绍了有关C 编程语言的入门知识。希望此资源能帮助C 编程初学者入门,本资源旨在培养C 编程兴趣。
社区模板帮助中心,点此进入>>
本资源名为“CPlusPlus编程语言基础”,由袁宵归纳整理出五千余条C 知识点,几乎涵盖了C 基础的所有知识,希望此资源能帮助C 初学者入门和C 使用者参考。
计算机专业同学福利!总结了6年的JavaScript 高级程序设计思维导图,非常全面非常细致。需要拿去!Java具有简单性、面向对象、分布式、健壮性、安全性、平台独立与可移植性、多线程、动态性等特点。Java可以编写桌面应用程序、Web应用程序、分布式系统和嵌入式系统应用程序等。还不快收藏学起来!
本资源名为“CPlusPlus入门”,系统性地介绍了有关C 编程语言的入门知识。希望此资源能帮助C 编程初学者入门,本资源旨在培养C 编程兴趣。
Java手册
本资源名为“Java手册”,主要内容来自廖雪峰的Java教程,是专门针对小白的零基础Java教程。 Java是一门面向对象编程语言,不仅吸收了C++语言的各种优点,还摒弃了C++里难以理解的多继承、指针等概念,因此Java语言具有功能强大和简单易用两个特征。
相关链接
Java官网
廖雪峰的官方网站-Java教程
Java关键字
本资源作者 袁宵
Java程序基础
Java程序基本结构:class
我们先剖析一个完整的Java程序,它的基本结构是什么: /** * 可以用来自动创建文档的注释 */ public class Hello { public static void main(String[] args) { // 向屏幕输出文本: System.out.println("Hello, world!"); /* 多行注释开始 注释内容 注释结束 */ } } // class定义结束 因为Java是面向对象的语言,一个程序的基本单位就是class,class是关键字,这里定义的class名字就是Hello: public class Hello { // 类名是Hello // ... } // class定义结束 类名要求: 类名必须以英文字母开头,后接字母,数字和下划线的组合,习惯以大写字母开头。 要注意遵守命名习惯,好的类命名: Hello NoteBook VRPlayer 注意到public是访问修饰符,表示该class是公开的。 不写public,也能正确编译,但是这个类将无法从命令行执行。 在class内部,可以定义若干方法(method): public class Hello { public static void main(String[] args) { // 方法名是main // 方法代码... } // 方法定义结束 } 方法定义了一组执行语句,方法内部的代码将会被依次顺序执行。 这里的方法名是main,返回值是void,表示没有任何返回值。 我们注意到public除了可以修饰class外,也可以修饰方法。而关键字static是另一个修饰符,它表示静态方法,后面我们会讲解方法的类型,目前,我们只需要知道,Java入口程序规定的方法必须是静态方法,方法名必须为main,括号内的参数必须是String数组。 方法名也有命名规则,命名和class一样,但是首字母小写: 好的方法命名: main goodMorning playVR 在方法内部,语句才是真正的执行代码。Java的每一行语句必须以分号结束: public class Hello { public static void main(String[] args) { System.out.println("Hello, world!"); // 语句 } } 在Java程序中,注释是一种给人阅读的文本,不是程序的一部分,所以编译器会自动忽略注释。
变量与常量
常量是不可变的量,C语言中数值可以用常量表示,常量可以表示各种数据类型的值。用标识符代表一个常量,称为符号常量。符号常量与变量不同,它的值在其作用域内不能改变,也不能再被赋值。使用符号常量的好处是:含义清楚;能做到“一改全改”。 变量和常量是相对的,变量在程序执行过程中是可变的量,由变量名和变量值组成,变量名是一个标识,变量值是一个数据值,程序为每个变量开辟了存储空间,变量值就存储在这个空间当中,可以通过变量名访问这个空间。
变量:基本类型的变量和引用类型的变量。
在Java中,变量分为两种:基本类型的变量和引用类型的变量。 我们先讨论基本类型的变量。 在Java中,变量必须先定义后使用,在定义变量的时候,可以给它一个初始值。例如: int x = 1; 上述语句定义了一个整型int类型的变量,名称为x,初始值为1。 不写初始值,就相当于给它指定了默认值。默认值总是0。 来看一个完整的定义变量,然后打印变量值的例子: // 定义并打印变量 public class Main { public static void main(String[] args) { int x = 100; // 定义int类型变量x,并赋予初始值100 System.out.println(x); // 打印该变量的值 } } 运行结果: 100 变量的一个重要特点是可以重新赋值。例如,对变量x,先赋值100,再赋值200,观察两次打印的结果: // 重新赋值变量 public class Main { public static void main(String[] args) { int x = 100; // 定义int类型变量x,并赋予初始值100 System.out.println(x); // 打印该变量的值,观察是否为100 x = 200; // 重新赋值为200 System.out.println(x); // 打印该变量的值,观察是否为200 } } 运行结果: 100 200 注意到第一次定义变量x的时候,需要指定变量类型int,因此使用语句int x = 100;。而第二次重新赋值的时候,变量x已经存在了,不能再重复定义,因此不能指定变量类型int,必须使用语句x = 200;。 变量不但可以重新赋值,还可以赋值给其他变量。让我们来看一个例子: // 变量之间的赋值 public class Main { public static void main(String[] args) { int n = 100; // 定义变量n,同时赋值为100 System.out.println("n = " + n); // 打印n的值 n = 200; // 变量n赋值为200 System.out.println("n = " + n); // 打印n的值 int x = n; // 变量x赋值为n(n的值为200,因此赋值后x的值也是200) System.out.println("x = " + x); // 打印x的值 x = x + 100; // 变量x赋值为x+100(x的值为200,因此赋值后x的值是200+100=300) System.out.println("x = " + x); // 打印x的值 System.out.println("n = " + n); // 再次打印n的值,n应该是200还是300? } } 运行结果: n = 100 n = 200 x = 200 x = 300 n = 200
常量 := final + 变量
定义变量的时候,如果加上final修饰符,这个变量就变成了常量: final double PI = 3.14; // PI是一个常量 double r = 5.0; double area = PI * r * r; PI = 300; // compile error! 常量在定义时进行初始化后就不可再次赋值,再次赋值会导致编译错误。 常量的作用是用有意义的变量名来避免魔术数字(Magic number),例如,不要在代码中到处写3.14,而是定义一个常量。如果将来需要提高计算精度,我们只需要在常量的定义处修改,例如,改成3.1416,而不必在所有地方替换3.14。 根据习惯,常量名通常全部大写。
基本数据类型及其运算
类型
整数类型:byte,short,int,long
对于整型类型,Java只定义了带符号的整型,因此,最高位的bit表示符号位(0表示正数,1表示负数)。各种整型能表示的最大范围如下: byte:-128 ~ 127 short: -32768 ~ 32767 int: -2147483648 ~ 2147483647 long: -9223372036854775808 ~ 9223372036854775807 我们来看定义整型的例子: // 定义整型 public class Main { public static void main(String[] args) { int i = 2147483647; int i2 = -2147483648; int i3 = 2_000_000_000; // 加下划线更容易识别 int i4 = 0xff0000; // 十六进制表示的16711680 int i5 = 0b1000000000; // 二进制表示的512 long l = 9000000000000000000L; // long型的结尾需要加L } } 特别注意:同一个数的不同进制的表示是完全相同的,例如15=0xf=0b1111。
浮点数类型:float,double
浮点类型的数就是小数,因为小数用科学计数法表示的时候,小数点是可以“浮动”的,如1234.5可以表示成12.345x102,也可以表示成1.2345x103,所以称为浮点数。 下面是定义浮点数的例子: float f1 = 3.14f; float f2 = 3.14e38f; // 科学计数法表示的3.14x10^38 double d = 1.79e308; double d2 = -1.79e308; double d3 = 4.9e-324; // 科学计数法表示的4.9x10^-324 对于float类型,需要加上f后缀。 浮点数可表示的范围非常大,float类型可最大表示3.4x1038,而double类型可最大表示1.79x10308。
字符类型:char
字符类型char是基本数据类型,它是character的缩写。一个char保存一个Unicode字符: char c1 = 'A'; char c2 = '中'; 因为Java在内存中总是使用Unicode表示字符,所以,一个英文字符和一个中文字符都用一个char类型表示,它们都占用两个字节。要显示一个字符的Unicode编码,只需将char类型直接赋值给int类型即可: int n1 = 'A'; // 字母“A”的Unicodde编码是65 int n2 = '中'; // 汉字“中”的Unicode编码是20013 还可以直接用转义字符\u+Unicode编码来表示一个字符: // 注意是十六进制: char c3 = '\u0041'; // 'A',因为十六进制0041 = 十进制65 char c4 = '\u4e2d'; // '中',因为十六进制4e2d = 十进制20013 字符类型char表示一个字符。Java的char类型除了可表示标准的ASCII外,还可以表示一个Unicode字符: // 字符类型 public class Main { public static void main(String[] args) { char a = 'A'; char zh = '中'; System.out.println(a); System.out.println(zh); } } 运行结果: A 中 注意char类型使用单引号',且仅有一个字符,要和双引号"的字符串类型区分开。
布尔类型:boolean
布尔类型boolean只有true和false两个值,布尔类型总是关系运算的计算结果: boolean b1 = true; boolean b2 = false; boolean isGreater = 5 > 3; // 计算结果为true int age = 12; boolean isAdult = age >= 18; // 计算结果为false Java语言对布尔类型的存储并没有做规定,因为理论上存储布尔类型只需要1 bit,但是通常JVM内部会把boolean表示为4字节整数。
运算
整数运算
四则运算
自增/自减:++、--
Java还提供了++运算和--运算,它们可以对一个整数进行加1和减1的操作: // 自增/自减运算 public class Main { public static void main(String[] args) { int n = 3300; n++; // 3301, 相当于 n = n + 1; n--; // 3300, 相当于 n = n - 1; int y = 100 + (++n); // 不要这么写 System.out.println(y); } } 运行结果: 3401
位移
在计算机中,整数总是以二进制的形式表示。例如,int类型的整数7使用4字节表示的二进制如下: 00000000 0000000 0000000 00000111 可以对整数进行移位运算。对整数7左移1位将得到整数14,左移两位将得到整数28: int n = 7; // 00000000 00000000 00000000 00000111 = 7 int a = n << 1; // 00000000 00000000 00000000 00001110 = 14 int b = n << 2; // 00000000 00000000 00000000 00011100 = 28 int c = n << 28; // 01110000 00000000 00000000 00000000 = 1879048192 int d = n << 29; // 11100000 00000000 00000000 00000000 = -536870912 左移29位时,由于最高位变成1,因此结果变成了负数。 类似的,对整数28进行右移,结果如下: int n = 7; // 00000000 00000000 00000000 00000111 = 7 int a = n >> 1; // 00000000 00000000 00000000 00000011 = 3 int b = n >> 2; // 00000000 00000000 00000000 00000001 = 1 int c = n >> 3; // 00000000 00000000 00000000 00000000 = 0 如果对一个负数进行右移,最高位的1不动,结果仍然是一个负数: int n = -536870912; int a = n >> 1; // 11110000 00000000 00000000 00000000 = -268435456 int b = n >> 2; // 11111000 00000000 00000000 00000000 = -134217728 int c = n >> 28; // 11111111 11111111 11111111 11111110 = -2 int d = n >> 29; // 11111111 11111111 11111111 11111111 = -1 还有一种不带符号的右移运算,使用>>>,它的特点是符号位跟着动,因此,对一个负数进行>>>右移,它会变成正数,原因是最高位的1变成了0: int n = -536870912; int a = n >>> 1; // 01110000 00000000 00000000 00000000 = 1879048192 int b = n >>> 2; // 00111000 00000000 00000000 00000000 = 939524096 int c = n >>> 29; // 00000000 00000000 00000000 00000111 = 7 int d = n >>> 31; // 00000000 00000000 00000000 00000001 = 1 对byte和short类型进行移位时,会首先转换为int再进行位移。 仔细观察可发现,左移实际上就是不断地×2,右移实际上就是不断地÷2。
位运算
位运算是按位进行与、或、非和异或的运算。 与运算的规则是,必须两个数同时为1,结果才为1: n = 0 & 0; // 0 n = 0 & 1; // 0 n = 1 & 0; // 0 n = 1 & 1; // 1 或运算的规则是,只要任意一个为1,结果就为1: n = 0 | 0; // 0 n = 0 | 1; // 1 n = 1 | 0; // 1 n = 1 | 1; // 1 非运算的规则是,0和1互换: n = ~0; // 1 n = ~1; // 0 异或运算的规则是,如果两个数不同,结果为1,否则为0: n = 0 ^ 0; // 0 n = 0 ^ 1; // 1 n = 1 ^ 0; // 1 n = 1 ^ 1; // 0 对两个整数进行位运算,实际上就是按位对齐,然后依次对每一位进行运算。例如: // 位运算 public class Main { public static void main(String[] args) { int i = 167776589; // 00001010 00000000 00010001 01001101 int n = 167776512; // 00001010 00000000 00010001 00000000 System.out.println(i & n); // 167776512 } } 运行结果: 167776512 上述按位与运算实际上可以看作两个整数表示的IP地址10.0.17.77和10.0.17.0,通过与运算,可以快速判断一个IP是否在给定的网段内。
相关概念
运算优先级
在Java的计算表达式中,运算优先级从高到低依次是: ( ) ! ~ ++ -- * / % + - << >> >>> & | += -= *= /= 记不住也没关系,只需要加括号就可以保证运算的优先级正确。
溢出
要特别注意,整数由于存在范围限制,如果计算结果超出了范围,就会产生溢出,而溢出不会出错,却会得到一个奇怪的结果: // 运算溢出 public class Main { public static void main(String[] args) { int x = 2147483640; int y = 15; int sum = x + y; System.out.println(sum); // -2147483641 } } 运算结果: -2147483641
类型转换
在运算过程中,如果参与运算的两个数类型不一致,那么计算结果为较大类型的整型。例如,short和int计算,结果总是int,原因是short首先自动被转型为int: // 类型自动提升与强制转型 public class Main { public static void main(String[] args) { short s = 1234; int i = 123456; int x = s + i; // s自动转型为int short y = s + i; // 编译错误! } } 运行结果: Main.java:7: 错误: 不兼容的类型: 从int转换到short可能会有损失 short y = s + i; // 编译错误! ^ 1 个错误 错误: 编译失败 也可以将结果强制转型,即将大范围的整数转型为小范围的整数。强制转型使用(类型),例如,将int强制转型为short: int i = 12345; short s = (short) i; // 12345 要注意,超出范围的强制转型会得到错误的结果,原因是转型时,int的两个高位字节直接被扔掉,仅保留了低位的两个字节: // 强制转型 public class Main { public static void main(String[] args) { int i1 = 1234567; short s1 = (short) i1; // -10617 System.out.println(s1); int i2 = 12345678; short s2 = (short) i2; // 24910 System.out.println(s2); } } 运行结果: -10617 24910 因此,强制转型的结果很可能是错的。
浮点数运算
四则运算
相关概念
溢出
整数运算在除数为0时会报错,而浮点数运算在除数为0时,不会报错,但会返回几个特殊值: NaN表示Not a Number Infinity表示无穷大 -Infinity表示负无穷大 例如: double d1 = 0.0 / 0; // NaN double d2 = 1.0 / 0; // Infinity double d3 = -1.0 / 0; // -Infinity 这三种特殊值在实际运算中很少碰到,我们只需要了解即可。
强制转型
可以将浮点数强制转型为整数。在转型时,浮点数的小数部分会被丢掉。如果转型后超过了整型能表示的最大范围,将返回整型的最大值。例如: int n1 = (int) 12.3; // 12 int n2 = (int) 12.7; // 12 int n2 = (int) -12.7; // -12 int n3 = (int) (12.7 + 0.5); // 13 int n4 = (int) 1.2e20; // 2147483647 如果要进行四舍五入,可以对浮点数加上0.5再强制转型: // 四舍五入 public class Main { public static void main(String[] args) { double d = 2.6; int n = (int) (d + 0.5); System.out.println(n); } } 运行结果: 3
类型提升
如果参与运算的两个数其中一个是整型,那么整型可以自动提升到浮点型: // 类型提升 public class Main { public static void main(String[] args) { int n = 5; double d = 1.2 + 24.0 / n; // 6.0 System.out.println(d); } } 运行结果: 6.0 需要特别注意,在一个复杂的四则运算中,两个整数的运算不会出现自动提升的情况。例如: double d = 1.2 + 24 / 5; // 5.2 计算结果为5.2,原因是编译器计算24 / 5这个子表达式时,按两个整数进行运算,结果仍为整数4。
布尔运算
对于布尔类型boolean,永远只有true和false两个值。 布尔运算是一种关系运算,包括以下几类: 比较运算符:>,>=,<,<=,==,!= 与运算 && 或运算 || 非运算 ! 下面是一些示例: boolean isGreater = 5 > 3; // true int age = 12; boolean isZero = age == 0; // false boolean isNonZero = !isZero; // true boolean isAdult = age >= 18; // false boolean isTeenager = age >6 && age <18; // true 关系运算符的优先级从高到低依次是: ! >,>=,<,<= ==,!= && ||
比较运算符:>,>=,<,<=,==,!=
与运算: &&
或运算: ||
非运算: !
三元运算符:b ? x : y
Java还提供一个三元运算符b ? x : y,它根据第一个布尔表达式的结果,分别返回后续两个表达式之一的计算结果。示例: // 三元运算 public class Main { public static void main(String[] args) { int n = -100; int x = n >= 0 ? n : -n; System.out.println(x); } } 运行结果: 100 上述语句的意思是,判断n >= 0是否成立,如果为true,则返回n,否则返回-n。这实际上是一个求绝对值的表达式。 注意到三元运算b ? x : y会首先计算b,如果b为true,则只计算x,否则,只计算y。此外,x和y的类型必须相同,因为返回值不是boolean,而是x和y之一。
相关概念
短路运算:如果一个布尔运算的表达式能提前确定结果,则后续的计算不再执行,直接返回结果。
布尔运算的一个重要特点是短路运算。如果一个布尔运算的表达式能提前确定结果,则后续的计算不再执行,直接返回结果。 因为false && x的结果总是false,无论x是true还是false,因此,与运算在确定第一个值为false后,不再继续计算,而是直接返回false。 我们考察以下代码: // 短路运算 public class Main { public static void main(String[] args) { boolean b = 5 < 3; boolean result = b && (5 / 0 > 0); System.out.println(result); } } 运行结果: false 如果没有短路运算,&&后面的表达式会由于除数为0而报错,但实际上该语句并未报错,原因在于与运算是短路运算符,提前计算出了结果false。 如果变量b的值为true,则表达式变为true && (5 / 0 > 0)。因为无法进行短路运算,该表达式必定会由于除数为0而报错,可以自行测试。 类似的,对于||运算,只要能确定第一个值为true,后续计算也不再进行,而是直接返回true: boolean result = true || (5 / 0 > 0); // true
关系运算优先级
关系运算符的优先级从高到低依次是: ! >,>=,<,<= ==,!= && ||
字符串类型
和char类型不同,字符串类型String是引用类型,我们用双引号"..."表示字符串。一个字符串可以存储0个到任意个字符: String s = ""; // 空字符串,包含0个字符 String s1 = "A"; // 包含一个字符 String s2 = "ABC"; // 包含3个字符 String s3 = "中文 ABC"; // 包含6个字符,其中有一个空格 因为字符串使用双引号"..."表示开始和结束,那如果字符串本身恰好包含一个"字符怎么表示?例如,"abc"xyz",编译器就无法判断中间的引号究竟是字符串的一部分还是表示字符串结束。这个时候,我们需要借助转义字符\: String s = "abc\"xyz"; // 包含7个字符: a, b, c, ", x, y, z 因为\是转义字符,所以,两个\\表示一个\字符: String s = "abc\\xyz"; // 包含7个字符: a, b, c, \, x, y, z 常见的转义字符包括: \" 表示字符" \' 表示字符' \\ 表示字符\ \n 表示换行符 \r 表示回车符 \t 表示Tab \u#### 表示一个Unicode编码的字符 例如: String s = "ABC\n\u4e2d\u6587"; // 包含6个字符: A, B, C, 换行符, 中, 文
字符串连接:+
Java的编译器对字符串做了特殊照顾,可以使用+连接任意字符串和其他数据类型,这样极大地方便了字符串的处理。例如: // 字符串连接 public class Main { public static void main(String[] args) { String s1 = "Hello"; String s2 = "world"; String s = s1 + " " + s2 + "!"; System.out.println(s); } } 运行结果: Hello world! 如果用+连接字符串和其他数据类型,会将其他数据类型先自动转型为字符串,再连接: // 字符串连接 public class Main { public static void main(String[] args) { int age = 25; String s = "age is " + age; System.out.println(s); } } 运行结果: age is 25
空值null与空字符串
引用类型的变量可以指向一个空值null,它表示不存在,即该变量不指向任何对象。例如: String s1 = null; // s1是null String s2; // 没有赋初值值,s2也是null String s3 = s1; // s3也是null String s4 = ""; // s4指向空字符串,不是null 注意要区分空值null和空字符串"",空字符串是一个有效的字符串对象,它不等于null。
数组类型:int[] ns = new int[5]
定义一个数组类型的变量,使用数组类型“类型[]”,例如,int[]。和单个基本类型变量不同,数组变量初始化必须使用new int[5]表示创建一个可容纳5个int元素的数组。 Java的数组有几个特点: 数组所有元素初始化为默认值,整型都是0,浮点型是0.0,布尔型是false; 数组一旦创建后,大小就不可改变。 要访问数组中的某一个元素,需要使用索引。数组索引从0开始,例如,5个元素的数组,索引范围是0~4。 可以修改数组中的某一个元素,使用赋值语句,例如,ns[1] = 79;。 可以用数组变量.length获取数组大小: // 数组 public class Main { public static void main(String[] args) { // 5位同学的成绩: int[] ns = new int[5]; System.out.println(ns.length); // 5 } } Run 5 数组是引用类型,在使用索引访问数组元素时,如果索引超出范围,运行时将报错: // 数组 public class Main { public static void main(String[] args) { // 5位同学的成绩: int[] ns = new int[5]; int n = 5; System.out.println(ns[n]); // 索引n不能超出范围 } } Run Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: Index 5 out of bounds for length 5 at Main.main(Main.java:7) 也可以在定义数组时直接指定初始化的元素,这样就不必写出数组大小,而是由编译器自动推算数组大小。例如: // 数组 public class Main { public static void main(String[] args) { // 5位同学的成绩: int[] ns = new int[] { 68, 79, 91, 85, 62 }; System.out.println(ns.length); // 编译器自动推算数组大小为5 } } Run 5 还可以进一步简写为: int[] ns = { 68, 79, 91, 85, 62 };
命令行参数String[ ] args
Java程序的入口是main方法,而main方法可以接受一个命令行参数,它是一个String[]数组。 这个命令行参数由JVM接收用户输入并传给main方法: public class Main { public static void main(String[] args) { for (String arg : args) { System.out.println(arg); } } } 我们可以利用接收到的命令行参数,根据不同的参数执行不同的代码。例如,实现一个-version参数,打印程序版本号: public class Main { public static void main(String[] args) { for (String arg : args) { if ("-version".equals(arg)) { System.out.println("v 1.0"); break; } } } } 上面这个程序必须在命令行执行,我们先编译它: $ javac Main.java 然后,执行的时候,给它传递一个-version参数: $ java Main -version v 1.0 这样,程序就可以根据传入的命令行参数,作出不同的响应。
关键字
var
var关键字 有些时候,类型的名字太长,写起来比较麻烦。例如: StringBuilder sb = new StringBuilder(); 这个时候,如果想省略变量类型,可以使用var关键字: var sb = new StringBuilder(); 编译器会根据赋值语句自动推断出变量sb的类型是StringBuilder。对编译器来说,语句: var sb = new StringBuilder(); 实际上会自动变成: StringBuilder sb = new StringBuilder(); 因此,使用var定义变量,仅仅是少写了变量类型而已。
流程控制
输入和输出
输出:System.out.println(输出的内容)
println是print line的缩写,表示输出并换行。因此,如果输出后不想换行,可以用print(): // 输出 public class Main { public static void main(String[] args) { System.out.print("A,"); System.out.print("B,"); System.out.print("C."); System.out.println(); System.out.println("END"); } } 运行结果: A,B,C. END 格式化输出 Java还提供了格式化输出的功能。为什么要格式化输出?因为计算机表示的数据不一定适合人来阅读: // 格式化输出 public class Main { public static void main(String[] args) { double d = 12900000; System.out.println(d); // 1.29E7 } } 运行结果: 1.29E7 如果要把数据显示成我们期望的格式,就需要使用格式化输出的功能。格式化输出使用System.out.printf(),通过使用占位符%?,printf()可以把后面的参数格式化成指定格式: // 格式化输出 public class Main { public static void main(String[] args) { double d = 3.1415926; System.out.printf("%.2f\n", d); // 显示两位小数3.14 System.out.printf("%.4f\n", d); // 显示4位小数3.1416 } } Java的格式化功能提供了多种占位符,可以把各种数据类型“格式化”成指定的字符串: 占位符 说明 %d 格式化输出整数 %x 格式化输出十六进制整数 %f 格式化输出浮点数 %e 格式化输出科学计数法表示的浮点数 %s 格式化字符串 注意,由于%表示占位符,因此,连续两个%%表示一个%字符本身。 占位符本身还可以有更详细的格式化参数。下面的例子把一个整数格式化成十六进制,并用0补足8位: // 格式化输出 public class Main { public static void main(String[] args) { int n = 12345000; System.out.printf("n=%d, hex=%08x", n, n); // 注意,两个%占位符必须传入两个数 } } 运行结果: n=12345000, hex=00bc5ea8
输入:Scanner scanner = new Scanner(System.in); String name = scanner.nextLine();
和输出相比,Java的输入就要复杂得多。 我们先看一个从控制台读取一个字符串和一个整数的例子: import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner scanner = new Scanner(System.in); // 创建Scanner对象 System.out.print("Input your name: "); // 打印提示 String name = scanner.nextLine(); // 读取一行输入并获取字符串 System.out.print("Input your age: "); // 打印提示 int age = scanner.nextInt(); // 读取一行输入并获取整数 System.out.printf("Hi, %s, you are %d\n", name, age); // 格式化输出 } } 首先,我们通过import语句导入java.util.Scanner,import是导入某个类的语句,必须放到Java源代码的开头,后面我们在Java的package中会详细讲解如何使用import。 然后,创建Scanner对象并传入System.in。System.out代表标准输出流,而System.in代表标准输入流。直接使用System.in读取用户输入虽然是可以的,但需要更复杂的代码,而通过Scanner就可以简化后续的代码。 有了Scanner对象后,要读取用户输入的字符串,使用scanner.nextLine(),要读取用户输入的整数,使用scanner.nextInt()。Scanner会自动转换数据类型,因此不必手动转换。 要测试输入,我们不能在线运行它,因为输入必须从命令行读取,因此,需要走编译、执行的流程: javac Main.java
if判断
在Java程序中,如果要根据条件来决定是否执行某一段代码,就需要if语句。 if语句的基本语法是: if (条件) { // 条件满足时执行 } 根据if的计算结果(true还是false),JVM决定是否执行if语句块(即花括号{}包含的所有语句)。 让我们来看一个例子: // 条件判断 public class Main { public static void main(String[] args) { int n = 70; if (n >= 60) { System.out.println("及格了"); } System.out.println("END"); } } 运行结果: 及格了 END 当条件n >= 60计算结果为true时,if语句块被执行,将打印"及格了",否则,if语句块将被跳过。修改n的值可以看到执行效果。
if ... else if ..
可以用多个if ... else if ...串联。例如: // 条件判断 public class Main { public static void main(String[] args) { int n = 70; if (n >= 90) { System.out.println("优秀"); } else if (n >= 60) { System.out.println("及格了"); } else { System.out.println("挂科了"); } System.out.println("END"); } } 运行结果: 及格了 END
判断引用类型相等:equals( )方法
在Java中,判断值类型的变量是否相等,可以使用==运算符。但是,判断引用类型的变量是否相等,==表示“引用是否相等”,或者说,是否指向同一个对象。例如,下面的两个String类型,它们的内容是相同的,但是,分别指向不同的对象,用==判断,结果为false: // 条件判断 public class Main { public static void main(String[] args) { String s1 = "hello"; String s2 = "HELLO".toLowerCase(); System.out.println(s1); System.out.println(s2); if (s1 == s2) { System.out.println("s1 == s2"); } else { System.out.println("s1 != s2"); } } } 运行结果: hello hello s1 != s2 要判断引用类型的变量内容是否相等,必须使用equals()方法: // 条件判断 public class Main { public static void main(String[] args) { String s1 = "hello"; String s2 = "HELLO".toLowerCase(); System.out.println(s1); System.out.println(s2); if (s1.equals(s2)) { System.out.println("s1 equals s2"); } else { System.out.println("s1 not equals s2"); } } } 运行结果: hello hello s1 equals s2 注意:执行语句s1.equals(s2)时,如果变量s1为null,会报NullPointerException: // 条件判断 public class Main { public static void main(String[] args) { String s1 = null; if (s1.equals("hello")) { System.out.println("hello"); } } } 运行结果: Exception in thread "main" java.lang.NullPointerException at Main.main(Main.java:5) 要避免NullPointerException错误,可以利用短路运算符&&: // 条件判断 public class Main { public static void main(String[] args) { String s1 = null; if (s1 != null && s1.equals("hello")) { System.out.println("hello"); } } } 还可以把一定不是null的对象"hello"放到前面:例如:if ("hello".equals(s)) { ... }。
switch多重选择
除了if语句外,还有一种条件判断,是根据某个表达式的结果,分别去执行不同的分支。 例如,在游戏中,让用户选择选项: 单人模式 多人模式 退出游戏 这时,switch语句就派上用场了。 switch语句根据switch (表达式)计算的结果,跳转到匹配的case结果,然后继续执行后续语句,直到遇到break结束执行。 我们看一个例子: // switch public class Main { public static void main(String[] args) { int option = 1; switch (option) { case 1: System.out.println("Selected 1"); break; case 2: System.out.println("Selected 2"); break; case 3: System.out.println("Selected 3"); break; } } } 运行结果: Selected 1 如果option的值没有匹配到任何case,例如option = 99,那么,switch语句不会执行任何语句。这时,可以给switch语句加一个default,当没有匹配到任何case时,执行default: // switch public class Main { public static void main(String[] args) { int option = 99; switch (option) { case 1: System.out.println("Selected 1"); break; case 2: System.out.println("Selected 2"); break; case 3: System.out.println("Selected 3"); break; default: System.out.println("Not selected"); break; } } } 运行结果: Not selected 如果把switch语句翻译成if语句,那么上述的代码相当于: if (option == 1) { System.out.println("Selected 1"); } else if (option == 2) { System.out.println("Selected 2"); } else if (option == 3) { System.out.println("Selected 3"); } else { System.out.println("Not selected"); } 对于多个==判断的情况,使用switch结构更加清晰。
switch语句还可以匹配字符串。字符串匹配时,是比较“内容相等”
switch语句还可以匹配字符串。字符串匹配时,是比较“内容相等”。例如: // switch public class Main { public static void main(String[] args) { String fruit = "apple"; switch (fruit) { case "apple": System.out.println("Selected apple"); break; case "pear": System.out.println("Selected pear"); break; case "mango": System.out.println("Selected mango"); break; default: System.out.println("No fruit selected"); break; } } } 运行结果: Selected apple
穿透性
使用switch时,注意case语句并没有花括号{},而且,case语句具有“穿透性”,漏写break将导致意想不到的结果: // switch public class Main { public static void main(String[] args) { int option = 2; switch (option) { case 1: System.out.println("Selected 1"); case 2: System.out.println("Selected 2"); case 3: System.out.println("Selected 3"); default: System.out.println("Not selected"); } } } 运行结果: Selected 2 Selected 3 Not selected 当option = 2时,将依次输出"Selected 2"、"Selected 3"、"Not selected",原因是从匹配到case 2开始,后续语句将全部执行,直到遇到break语句。因此,任何时候都不要忘记写break。
循环
循环语句就是让计算机根据条件做循环计算,在条件满足时继续循环,条件不满足时退出循环。
while循环
Java提供的while条件循环。它的基本用法是: while (条件表达式) { 循环语句 } // 继续执行后续代码 while循环在每次循环开始前,首先判断条件是否成立。如果计算结果为true,就把循环体内的语句执行一遍,如果计算结果为false,那就直接跳到while循环的末尾,继续往下执行。 我们用while循环来累加1到100,可以这么写: // while public class Main { public static void main(String[] args) { int sum = 0; // 累加的和,初始化为0 int n = 1; while (n <= 100) { // 循环条件是n <= 100 sum = sum + n; // 把n累加到sum中 n ++; // n自身加1 } System.out.println(sum); // 5050 } } 运行结果: 5050
do while循环
在Java中,while循环是先判断循环条件,再执行循环。而另一种do while循环则是先执行循环,再判断条件,条件满足时继续循环,条件不满足时退出。它的用法是: do { 执行循环语句 } while (条件表达式); 可见,do while循环会至少循环一次。 我们把对1到100的求和用do while循环改写一下: // do-while public class Main { public static void main(String[] args) { int sum = 0; int n = 1; do { sum = sum + n; n ++; } while (n <= 100); System.out.println(sum); } } 运行结果: 5050 使用do while循环时,同样要注意循环条件的判断。 注意到while循环是先判断循环条件,再循环,因此,有可能一次循环都不做。
for循环
for循环的功能非常强大,它使用计数器实现循环。for循环会先初始化计数器,然后,在每次循环前检测循环条件,在每次循环后更新计数器。计数器变量通常命名为i。 我们把1到100求和用for循环改写一下: // for public class Main { public static void main(String[] args) { int sum = 0; for (int i=1; i<=100; i++) { sum = sum + i; } System.out.println(sum); } } 运行结果: 5050 在for循环执行前,会先执行初始化语句int i=1,它定义了计数器变量i并赋初始值为1,然后,循环前先检查循环条件i<=100,循环后自动执行i++,因此,和while循环相比,for循环把更新计数器的代码统一放到了一起。在for循环的循环体内部,不需要去更新变量i。 因此,for循环的用法是: for (初始条件; 循环检测条件; 循环后更新计数器) { // 执行语句 }
循环控制语句
break:跳出当前循环
continue:提前结束本次循环,直接继续执行下次循环
面向对象基础
面向对象基础
定义类与创建实例
理解类与实例 面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。 现实世界中,我们定义了“人”这种抽象概念,而具体的人则是“小明”、“小红”、“小军”等一个个具体的人。所以,“人”可以定义为一个类(class),而具体的人则是实例(instance)。 class是一种对象模版,它定义了如何创建实例,因此,class本身就是一种数据类型。 而instance是对象实例,instance是根据class创建的实例,可以创建多个instance,每个instance类型相同,但各自属性可能不相同。 定义class 在Java中,创建一个类,例如,给这个类命名为Person,就是定义一个class: class Person { public String name; public int age; } 一个class可以包含多个字段(field),字段用来描述一个类的特征。上面的Person类,我们定义了两个字段,一个是String类型的字段,命名为name,一个是int类型的字段,命名为age。因此,通过class,把一组数据汇集到一个对象上,实现了数据封装。 public是用来修饰字段的,它表示这个字段可以被外部访问。 我们再看另一个Book类的定义: class Book { public String name; public String author; public String isbn; public double price; } 请指出Book类的各个字段。 创建实例 定义了class,只是定义了对象模版,而要根据对象模版创建出真正的对象实例,必须用new操作符。 new操作符可以创建一个实例,然后,我们需要定义一个引用类型的变量来指向这个实例: Person ming = new Person(); 上述代码创建了一个Person类型的实例,并通过变量ming指向它。 注意区分Person ming是定义Person类型的变量ming,而new Person()是创建Person实例。 有了指向这个实例的变量,我们就可以通过这个变量来操作实例。访问实例变量可以用变量.字段,例如: ming.name = "Xiao Ming"; // 对字段name赋值 ming.age = 12; // 对字段age赋值 System.out.println(ming.name); // 访问字段name Person hong = new Person(); hong.name = "Xiao Hong"; hong.age = 15; 两个instance拥有class定义的name和age字段,且各自都有一份独立的数据,互不干扰。
方法
定义方法的语法是: 修饰符 方法返回类型 方法名(方法参数列表) { 若干方法语句; return 方法返回值; } 方法返回值通过return语句实现,如果没有返回值,返回类型设置为void,可以省略return。
构造方法
目的:在创建对象实例时就把内部字段全部初始化为合适的值
能否在创建对象实例时就把内部字段全部初始化为合适的值?完全可以。这时,我们就需要构造方法。 创建实例的时候,实际上是通过构造方法来初始化实例的。我们先来定义一个构造方法,能在创建Person实例的时候,一次性传入name和age,完成初始化: // 构造方法 public class Main { public static void main(String[] args) { Person p = new Person("Xiao Ming", 15); System.out.println(p.getName()); System.out.println(p.getAge()); } } class Person { private String name; private int age; public Person(String name, int age) { this.name = name; this.age = age; } public String getName() { return this.name; } public int getAge() { return this.age; } } 运行结果: Xiao Ming 15 由于构造方法是如此特殊,所以构造方法的名称就是类名。构造方法的参数没有限制,在方法内部,也可以编写任意语句。但是,和普通方法相比,构造方法没有返回值(也没有void),调用构造方法,必须用new操作符。
默认构造方法
是不是任何class都有构造方法?是的。那前面我们并没有为Person类编写构造方法,为什么可以调用new Person()?原因是如果一个类没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数,也没有执行语句,类似这样: class Person { public Person() { } } 要特别注意的是,如果我们自定义了一个构造方法,那么,编译器就不再自动创建默认构造方法: // 构造方法 public class Main { public static void main(String[] args) { Person p = new Person(); // 编译错误:找不到这个构造方法 } } class Person { private String name; private int age; public Person(String name, int age) { this.name = name; this.age = age; } public String getName() { return this.name; } public int getAge() { return this.age; } } 运行结果: Main.java:4: 错误: 无法将类 Person中的构造器 Person应用到给定类型; Person p = new Person(); // 编译错误:找不到这个构造方法 ^ 需要: String,int 找到: 没有参数 原因: 实际参数列表和形式参数列表长度不同 1 个错误 错误: 编译失败 如果既要能使用带参数的构造方法,又想保留不带参数的构造方法,那么只能把两个构造方法都定义出来: // 构造方法 public class Main { public static void main(String[] args) { Person p1 = new Person("Xiao Ming", 15); // 既可以调用带参数的构造方法 Person p2 = new Person(); // 也可以调用无参数构造方法 } } class Person { private String name; private int age; public Person() { } public Person(String name, int age) { this.name = name; this.age = age; } public String getName() { return this.name; } public int getAge() { return this.age; } } 没有在构造方法中初始化字段时,引用类型的字段默认是null,数值类型的字段用默认值,int类型默认值是0,布尔类型默认值是false: class Person { private String name; // 默认初始化为null private int age; // 默认初始化为0 public Person() { } }
初始化字段
可以对字段直接进行初始化: class Person { private String name = "Unamed"; private int age = 10; } 那么问题来了:既对字段进行初始化,又在构造方法中对字段进行初始化: class Person { private String name = "Unamed"; private int age = 10; public Person(String name, int age) { this.name = name; this.age = age; } } 当我们创建对象的时候,new Person("Xiao Ming", 12)得到的对象实例,字段的初始值是啥? 在Java中,创建对象实例的时候,按照如下顺序进行初始化: 先初始化字段,例如,int age = 10;表示字段初始化为10,double salary;表示字段默认初始化为0,String name;表示引用类型字段默认初始化为null; 执行构造方法的代码进行初始化。 因此,构造方法的代码由于后运行,所以,new Person("Xiao Ming", 12)的字段值最终由构造方法的代码确定。
多构造方法
可以定义多个构造方法,在通过new操作符调用的时候,编译器通过构造方法的参数数量、位置和类型自动区分: class Person { private String name; private int age; public Person(String name, int age) { this.name = name; this.age = age; } public Person(String name) { this.name = name; this.age = 12; } public Person() { } } 如果调用new Person("Xiao Ming", 20);,会自动匹配到构造方法public Person(String, int)。 如果调用new Person("Xiao Ming");,会自动匹配到构造方法public Person(String)。 如果调用new Person();,会自动匹配到构造方法public Person()。 一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是this(…): class Person { private String name; private int age; public Person(String name, int age) { this.name = name; this.age = age; } public Person(String name) { this(name, 18); // 调用另一个构造方法Person(String, int) } public Person() { this("Unnamed"); // 调用另一个构造方法Person(String) } }
this变量
在方法内部,可以使用一个隐含的变量this,它始终指向当前实例。因此,通过this.field就可以访问当前实例的字段。 如果没有命名冲突,可以省略this。例如: class Person { private String name; public String getName() { return name; // 相当于this.name } } 但是,如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上this: class Person { private String name; public void setName(String name) { this.name = name; // 前面的this不可少,少了就变成局部变量name了 } }
参数
形式参数与实际参数
形式参数:是在定义函数名和函数体的时候使用的参数,目的是用来接收调用该函数时传入的参数,简称“形参”。
实际参数:在主调函数中调用一个函数时,函数名后面括号中的参数称为“实际参数”,简称“实参”。
参数传递:java中方法参数传递方式是按值传递。
如果参数是基本类型,传递的是基本类型的字面量值的拷贝。
如果参数是引用类型,传递的是该参量所引用的对象在堆中地址值的拷贝。
值传递与引用传递的根本区别是会不会创建传递对象的副本。
参数绑定
基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方。
方法重载:方法名相同,但各自的参数不同,称为方法重载
在一个类中,我们可以定义多个方法。如果有一系列方法,它们的功能都是类似的,只有参数有所不同,那么,可以把这一组方法名做成同名方法。例如,在Hello类中,定义多个hello()方法: class Hello { public void hello() { System.out.println("Hello, world!"); } public void hello(String name) { System.out.println("Hello, " + name + "!"); } public void hello(String name, int age) { if (age < 18) { System.out.println("Hi, " + name + "!"); } else { System.out.println("Hello, " + name + "!"); } } } 这种方法名相同,但各自的参数不同,称为方法重载(Overload)。 注意:方法重载的返回值类型通常都是相同的。 方法重载的目的是,功能类似的方法使用同一名字,更容易记住,因此,调用起来更简单。 举个例子,String类提供了多个重载方法indexOf(),可以查找子串: int indexOf(int ch):根据字符的Unicode码查找; int indexOf(String str):根据字符串查找; int indexOf(int ch, int fromIndex):根据字符查找,但指定起始位置; int indexOf(String str, int fromIndex)根据字符串查找,但指定起始位置。 试一试: // String.indexOf() public class Main { public static void main(String[] args) { String s = "Test string"; int n1 = s.indexOf('t'); int n2 = s.indexOf("st"); int n3 = s.indexOf("st", 4); System.out.println(n1); System.out.println(n2); System.out.println(n3); } } 运行结果: 3 2 5
访问控制修饰符
Java借助private、protected、public与默认修饰符提供了成员访问控制。适用于字段、方法或类。 注意:java的访问控制是停留在编译层的,也就是它不会在.class文件中留下任何的痕迹,只在编译的时候进行访问控制的检查。其实,通过反射的手段,是可以访问任何包下任何类中的成员,例如,访问类的私有成员也是可能的。
public:具有最大的访问权限,可以访问任何一个在classpath下的类、接口、异常等。它往往用于对外的情况,也就是对象或类对外的一种接口的形式。
protected:主要的作用就是用来保护子类的。它的含义在于子类可以用它修饰的成员,其他的不可以,它相当于传递给子类的一种继承的东西。
default:有时候也称为friendly,它是针对本包访问而设计的,任何处于本包下的类、接口、异常等,都可以相互访问,即使是父类没有用protected修饰的成员也可以。
private:访问权限仅限于类的内部,是一种封装的体现,例如,大多数成员变量都是修饰符为private的,它们不希望被其他任何外部的类访问。
继承:extends
继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。当我们让Student从Person继承时,Student就获得了Person的所有功能,我们只需要为Student编写新增的功能。 Java使用extends关键字来实现继承: class Person { private String name; private int age; public String getName() {...} public void setName(String name) {...} public int getAge() {...} public void setAge(int age) {...} } class Student extends Person { // 不要重复name和age字段/方法, // 只需要定义新增score字段/方法: private int score; public int getScore() { … } public void setScore(int score) { … } } 可见,通过继承,Student只需要编写额外的功能,不再需要重复代码。 在OOP的术语中,我们把Person称为超类(super class),父类(parent class),基类(base class),把Student称为子类(subclass),扩展类(extended class)。
定义:Java继承是面向对象的最显著的一个特征。继承是从已有的类中派生出新的类,新的类能吸收已有类的数据属性和行为,并能扩展新的能力。
作用:继承技术使得复用以前的代码非常容易,能够大大缩短开发周期,降低开发费用。
这种技术使得复用以前的代码非常容易,能够大大缩短开发周期,降低开发费用。比如可以先定义一个类叫车,车有以下属性:车体大小,颜色,方向盘,轮胎,而又由车这个类派生出轿车和卡车两个类,为轿车添加一个小后备箱,而为卡车添加一个大货箱。
关键字super:表示父类
super关键字表示父类(超类)。子类引用父类的字段时,可以用super.fieldName。例如: class Student extends Person { public String hello() { return "Hello, " + super.name; } } 实际上,这里使用super.name,或者this.name,或者name,效果都是一样的。编译器会自动定位到父类的name字段。 但是,在某些时候,就必须使用super。我们来看一个例子: // super public class Main { public static void main(String[] args) { Student s = new Student("Xiao Ming", 12, 89); } } class Person { protected String name; protected int age; public Person(String name, int age) { this.name = name; this.age = age; } } class Student extends Person { protected int score; public Student(String name, int age, int score) { this.score = score; } } 运行结果: Main.java:21: 错误: 无法将类 Person中的构造器 Person应用到给定类型; public Student(String name, int age, int score) { ^ 需要: String,int 找到: 没有参数 原因: 实际参数列表和形式参数列表长度不同 1 个错误 错误: 编译失败 运行上面的代码,会得到一个编译错误,大意是在Student的构造方法中,无法调用Person的构造方法。 这是因为在Java中,任何class的构造方法,第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句super();,所以,Student类的构造方法实际上是这样: class Student extends Person { protected int score; public Student(String name, int age, int score) { super(); // 自动调用父类的构造方法 this.score = score; } } 但是,Person类并没有无参数的构造方法,因此,编译失败。解决方法是调用Person类存在的某个构造方法。例如: class Student extends Person { protected int score; public Student(String name, int age, int score) { super(name, age); // 调用父类的构造方法Person(String, int) this.score = score; } } 这样就可以正常编译了!因此我们得出结论:如果父类没有默认的构造方法,子类就必须显式调用super()并给出参数以便让编译器定位到父类的一个合适的构造方法。这里还顺带引出了另一个问题:即子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。
向上转型:把一个子类类型安全地变为父类类型的赋值,被称为向上转型。
这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型(upcasting)。 向上转型实际上是把一个子类型安全地变为更加抽象的父类型: Student s = new Student(); Person p = s; // upcasting, ok Object o1 = p; // upcasting, ok Object o2 = s; // upcasting, ok 注意到继承树是Student > Person > Object,所以,可以把Student类型转型为Person,或者更高层次的Object。
向下转型:如果把一个父类类型强制转型为子类类型,就是向下转型。
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)。例如: Person p1 = new Student(); // upcasting, ok Person p2 = new Person(); Student s1 = (Student) p1; // ok Student s2 = (Student) p2; // runtime error! ClassCastException! 如果测试上面的代码,可以发现: Person类型p1实际指向Student实例,Person类型变量p2实际指向Person实例。在向下转型的时候,把p1转型为Student会成功,因为p1确实指向Student实例,把p2转型为Student会失败,因为p2的实际类型是Person,不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来。因此,向下转型很可能会失败。失败的时候,Java虚拟机会报ClassCastException。 为了避免向下转型出错,Java提供了instanceof操作符,可以先判断一个实例究竟是不是某种类型: Person p = new Person(); System.out.println(p instanceof Person); // true System.out.println(p instanceof Student); // false Student s = new Student(); System.out.println(s instanceof Person); // true System.out.println(s instanceof Student); // true Student n = null; System.out.println(n instanceof Student); // false instanceof实际上判断一个变量所指向的实例是否是指定类型,或者这个类型的子类。如果一个引用变量为null,那么对任何instanceof的判断都为false。利用instanceof,在向下转型前可以先判断: Person p = new Student(); if (p instanceof Student) { // 只有判断成功才会向下转型: Student s = (Student) p; // 一定会成功 }
操作符instanceof
继承与组合的区别:继承是is关系,组合是has关系。
在使用继承时,我们要注意逻辑一致性。 考察下面的Book类: class Book { protected String name; public String getName() {...} public void setName(String name) {...} } 这个Book类也有name字段,那么,我们能不能让Student继承自Book呢? class Student extends Book { protected int score; } 显然,从逻辑上讲,这是不合理的,Student不应该从Book继承,而应该从Person继承。 究其原因,是因为Student是Person的一种,它们是is关系,而Student并不是Book。实际上Student和Book的关系是has关系。 具有has关系不应该使用继承,而是使用组合,即Student可以持有一个Book实例: class Student extends Person { protected Book book; protected int score; } 因此,继承是is关系,组合是has关系。
多态
定义:同一操作作用于不同的对象,可以有不同的解释,产生不同的执行结果。在运行时,可以通过指向基类的指针,来调用实现派生类中的方法。
多态(Polymorphism)按字面的意思就是“多种状态”。在面向对象语言中,接口的多种不同的实现方式即为多态。引用Charlie Calverts对多态的描述——多态性是允许你将父对象设置成为一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作(摘自“Delphi4 编程技术内幕”)。简单的说,就是一句话:允许将子类类型的指针赋值给父类类型的指针。多态性在Object Pascal和C++中都是通过虚函数实现的。
作用:允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。把不同的子类对象都当作父类来看,可以屏蔽不同子类对象之间的差异,写出通用的代码,做出通用的编程,以适应需求的不断变化。
把不同的子类对象都当作父类来看,可以屏蔽不同子类对象之间的差异,写出通用的代码,做出通用的编程,以适应需求的不断变化。 赋值之后,父类型的引用就可以根据当前赋值给它的子对象的特性以不同的方式运作。也就是说,父亲的行为像儿子,而不是儿子的行为像父亲。 举个例子:从一个基类中派生,响应一个虚命令,产生不同的结果。 比如从某个基类派生出多个子类,其基类有一个虚方法Tdoit,然后其子类也有这个方法,但行为不同,然后这些子类对象中的任何一个可以赋给其基类对象的引用,或者说将子对象地址赋给基类指针,这样其基类的对象就可以执行不同的操作了。实际上你是在通过其基类的引用来访问其子类对象的,你要做的就是一个赋值操作。 使用继承性的结果就是当创建了一个类的家族,在认识这个类的家族时,就是把子类的对象当作基类的对象,这种认识又叫作upcasting(向上转型)。这样认识的重要性在于:我们可以只针对基类写出一段程序,但它可以适应于这个类的家族,因为编译器会自动找出合适的对象来执行操作。这种现象又称为多态性。而实现多态性的手段又叫称动态绑定(dynamic binding)。 简单的说,建立一个父类对象的引用,它所指对象可以是这个父类的对象,也可以是它的子类的对象。java中当子类拥有和父类同样的函数,当通过这个父类对象的引用调用这个函数的时候,调用到的是子类中的函数。
实现多态的方法:虚函数,抽象类,覆盖,模板
覆写
定义:在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写。
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。 例如,在Person类中,我们定义了run()方法: class Person { public void run() { System.out.println("Person.run"); } } 在子类Student中,覆写这个run()方法: class Student extends Person { @Override public void run() { System.out.println("Student.run"); } } Override和Overload不同的是,如果方法签名如果不同,就是Overload,Overload方法是一个新方法;如果方法签名相同,并且返回值也相同,就是Override。 注意:方法名相同,方法参数相同,但方法返回值不同,也是不同的方法。在Java程序中,出现这种情况,编译器会报错。 class Person { public void run() { … } } class Student extends Person { // 不是Override,因为参数不同: public void run(String s) { … } // 不是Override,因为返回值不同: public int run() { … } } 加上@Override可以让编译器帮助检查是否进行了正确的覆写。希望进行覆写,但是不小心写错了方法签名,编译器会报错,但是@Override不是必需的。 // override public class Main { public static void main(String[] args) { } } class Person { public void run() {} } public class Student extends Person { @Override // Compile error! public void run(String s) {} }
覆写Object方法
因为所有的class最终都继承自Object,而Object定义了几个重要的方法: toString():把instance输出为String; equals():判断两个instance是否逻辑相等; hashCode():计算一个instance的哈希值。 在必要的情况下,我们可以覆写Object的这几个方法。例如: class Person { ... // 显示更有意义的字符串: @Override public String toString() { return "Person:name=" + name; } // 比较是否相等: @Override public boolean equals(Object o) { // 当且仅当o为Person类型: if (o instanceof Person) { Person p = (Person) o; // 并且name字段相同时,返回true: return this.name.equals(p.name); } return false; } // 计算hash: @Override public int hashCode() { return this.name.hashCode(); } }
toString():把instance输出为String
equals():判断两个instance是否逻辑相等
hashCode():计算一个instance的哈希值
税收例子
假设我们定义一种收入,需要给它报税,那么先定义一个Income类: class Income { protected double income; public double getTax() { return income * 0.1; // 税率10% } } 对于工资收入,可以减去一个基数,那么我们可以从Income派生出SalaryIncome,并覆写getTax(): class Salary extends Income { @Override public double getTax() { if (income <= 5000) { return 0; } return (income - 5000) * 0.2; } } 如果你享受国务院特殊津贴,那么按照规定,可以全部免税: class StateCouncilSpecialAllowance extends Income { @Override public double getTax() { return 0; } } 现在,我们要编写一个报税的财务软件,对于一个人的所有收入进行报税,可以这么写: public double totalTax(Income... incomes) { double total = 0; for (Income income: incomes) { total = total + income.getTax(); } return total; } 来试一下: // Polymorphic public class Main { public static void main(String[] args) { // 给一个有普通收入、工资收入和享受国务院特殊津贴的小伙伴算税: Income[] incomes = new Income[] { new Income(3000), new Salary(7500), new StateCouncilSpecialAllowance(15000) }; System.out.println(totalTax(incomes)); } public static double totalTax(Income... incomes) { double total = 0; for (Income income: incomes) { total = total + income.getTax(); } return total; } } class Income { protected double income; public Income(double income) { this.income = income; } public double getTax() { return income * 0.1; // 税率10% } } class Salary extends Income { public Salary(double income) { super(income); } @Override public double getTax() { if (income <= 5000) { return 0; } return (income - 5000) * 0.2; } } class StateCouncilSpecialAllowance extends Income { public StateCouncilSpecialAllowance(double income) { super(income); } @Override public double getTax() { return 0; } } 运行结果: 800.0 观察totalTax()方法:利用多态,totalTax()方法只需要和Income打交道,它完全不需要知道Salary和StateCouncilSpecialAllowance的存在,就可以正确计算出总的税。如果我们要新增一种稿费收入,只需要从Income派生,然后正确覆写getTax()方法就可以。把新的类型传入totalTax(),不需要修改任何代码。 可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。
关键字final:用final修饰method可以阻止被子类覆写
Java还提供了一个final修饰符。final与访问权限不冲突,它有很多作用。 用final修饰class可以阻止被继承: package abc; // 无法被继承: public final class Hello { private int n = 0; protected void hi(int t) { long i = t; } } 用final修饰method可以阻止被子类覆写: package abc; public class Hello { // 无法被覆写: protected final void hi() { } } 用final修饰field可以阻止被重新赋值: package abc; public class Hello { private final int n = 0; protected void hi() { this.n = 1; // error! } } 用final修饰局部变量可以阻止被重新赋值: package abc; public class Hello { protected void hi(final int t) { t = 1; // error! } }
抽象类
定义:使用abstract修饰的类就是抽象类。
如果一个class定义了方法,但没有具体执行代码,这个方法就是抽象方法,抽象方法用abstract修饰。 因为无法执行抽象方法,因此这个类也必须申明为抽象类(abstract class)。 使用abstract修饰的类就是抽象类。我们无法实例化一个抽象类: Person p = new Person(); // 编译错误
作用:因为抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”。
例如,Person类定义了抽象方法run(),那么,在实现子类Student的时候,就必须覆写run()方法: // abstract class public class Main { public static void main(String[] args) { Person p = new Student(); p.run(); } } abstract class Person { public abstract void run(); } class Student extends Person { @Override public void run() { System.out.println("Student.run"); } } 运行结果: Student.run
面相抽象编程
当我们定义了抽象类Person,以及具体的Student、Teacher子类的时候,我们可以通过抽象类Person类型去引用具体的子类的实例: Person s = new Student(); Person t = new Teacher(); 这种引用抽象类的好处在于,我们对其进行方法调用,并不关心Person类型变量的具体子类型: // 不关心Person变量的具体子类型: s.run(); t.run(); 同样的代码,如果引用的是一个新的子类,我们仍然不关心具体类型: // 同样不关心新的子类是如何实现run()方法的: Person e = new Employee(); e.run(); 这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程。 面向抽象编程的本质就是: 上层代码只定义规范(例如:abstract class Person); 不需要子类就可以实现业务逻辑(正常编译); 具体的业务逻辑由不同的子类实现,调用者并不关心。
关键字abstract
接口
定义:interface就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。
在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力。 如果一个抽象类没有字段,所有方法全部都是抽象方法: abstract class Person { public abstract void run(); public abstract String getName(); } 就可以把该抽象类改写为接口:interface。 在Java中,使用interface可以声明一个接口: interface Person { void run(); String getName(); } 所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract的,所以这两个修饰符不需要写出来(写不写效果都一样)。 当一个具体的class去实现一个interface时,需要使用implements关键字。举个例子: class Student implements Person { private String name; public Student(String name) { this.name = name; } @Override public void run() { System.out.println(this.name + " run"); } @Override public String getName() { return this.name; } } 我们知道,在Java中,一个类只能继承自另一个类,不能从多个类继承。但是,一个类可以实现多个interface,例如: class Student implements Person, Hello { // 实现了两个interface ... }
抽象类与接口的区别
Java的接口特指interface的定义,表示一个接口类型和一组方法签名,而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。 抽象类和接口的对比如下: abstract class interface 继承 只能extends一个class 可以implements多个interface 字段 可以定义实例字段 不能定义实例字段 抽象方法 可以定义抽象方法 可以定义抽象方法 非抽象方法 可以定义非抽象方法 可以定义default方法
关键字interface
default方法
在接口中,可以定义default方法。例如,把Person接口的run()方法改为default方法: // interface public class Main { public static void main(String[] args) { Person p = new Student("Xiao Ming"); p.run(); } } interface Person { String getName(); default void run() { System.out.println(getName() + " run"); } } class Student implements Person { private String name; public Student(String name) { this.name = name; } public String getName() { return this.name; } } 运行结果: Xiao Ming run 实现类可以不必覆写default方法。default方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。 default方法和抽象类的普通方法是有所不同的。因为interface没有字段,default方法无法访问字段,而抽象类的普通方法可以访问实例字段。
静态字段和方法
静态字段:static 字段
在一个class中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。还有一种字段,是用static修饰的字段,称为静态字段:static field。实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段。举个例子: class Person { public String name; public int age; // 定义静态字段number: public static int number; } 我们来看看下面的代码: // static field public class Main { public static void main(String[] args) { Person ming = new Person("Xiao Ming", 12); Person hong = new Person("Xiao Hong", 15); ming.number = 88; System.out.println(hong.number); hong.number = 99; System.out.println(ming.number); } } class Person { public String name; public int age; public static int number; public Person(String name, int age) { this.name = name; this.age = age; } } 运行结果: 88 99 对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例。虽然实例可以访问静态字段,但是它们指向的其实都是Person class的静态字段。所以,所有实例共享一个静态字段。因此,不推荐用实例变量.静态字段去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段来访问静态对象。 推荐用类名来访问静态字段。可以把静态字段理解为描述class本身的字段(非实例字段)。对于上面的代码,更好的写法是: Person.number = 99; System.out.println(Person.number);
接口的静态字段
因为interface是一个纯抽象类,所以它不能定义实例字段。但是,interface是可以有静态字段的,并且静态字段必须为final类型: public interface Person { public static final int MALE = 1; public static final int FEMALE = 2; } 实际上,因为interface的字段只能是public static final类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为: public interface Person { // 编译器会自动加上public statc final: int MALE = 1; int FEMALE = 2; } 编译器会自动把该字段变为public static final类型。
静态方法:static 方法
静态字段,就有静态方法。用static修饰的方法称为静态方法。调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。例如: // static method public class Main { public static void main(String[] args) { Person.setNumber(99); System.out.println(Person.number); } } class Person { public static int number; public static void setNumber(int value) { number = value; } } 运行结果: 99 因为静态方法属于class而不属于实例,因此,静态方法内部,无法访问this变量,也无法访问实例字段,它只能访问静态字段。通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告。 静态方法经常用于工具类。例如: Arrays.sort() Math.random() 静态方法也经常用于辅助方法。注意到Java程序的入口main()也是静态方法。
静态变量、静态方法初始化顺序
包
Java定义了一种名字空间,称之为包:package。一个类总是属于某个包,类名(比如Person)只是一个简写,真正的完整类名是包名.类名。使用package来解决名字冲突
在前面的代码中,我们把类和接口命名为Person、Student、Hello等简单名字。 在现实中,如果小明写了一个Person类,小红也写了一个Person类,现在,小白既想用小明的Person,也想用小红的Person,怎么办?如果小军写了一个Arrays类,恰好JDK也自带了一个Arrays类,如何解决类名冲突? 在Java中,我们使用package来解决名字冲突。 Java定义了一种名字空间,称之为包:package。一个类总是属于某个包,类名(比如Person)只是一个简写,真正的完整类名是包名.类名。 例如: 小明的Person类存放在包ming下面,因此,完整类名是ming.Person; 小红的Person类存放在包hong下面,因此,完整类名是hong.Person; 小军的Arrays类存放在包mr.jun下面,因此,完整类名是mr.jun.Arrays; JDK的Arrays类存放在包java.util下面,因此,完整类名是java.util.Arrays。 在定义class的时候,我们需要在第一行声明这个class属于哪个包。 小明的Person.java文件: package ming; // 申明包名ming public class Person { } 小军的Arrays.java文件: package mr.jun; // 申明包名mr.jun public class Arrays { } 在Java虚拟机执行的时候,JVM只看完整类名,因此,只要包名不同,类就不同。包可以是多层结构,用.隔开。例如:java.util。 要特别注意:包没有父子关系。java.util和java.util.zip是不同的包,两者没有任何继承关系。 没有定义包名的class,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。
包作用域:位于同一个包的类,可以访问包作用域的字段和方法。不用public、protected、private修饰的字段和方法就是包作用域。
位于同一个包的类,可以访问包作用域的字段和方法。不用public、protected、private修饰的字段和方法就是包作用域。例如,Person类定义在hello包下面: package hello; public class Person { // 包作用域: void hello() { System.out.println("Hello!"); } } Main类也定义在hello包下面: package hello; public class Main { public static void main(String[] args) { Person p = new Person(); p.hello(); // 可以调用,因为Main和Person在同一个包 } }
import关键字:mport的目的是为了让你写Xyz不用写abc.def.Xyz,和访问权限是两回事
ava编译器最终编译出的.class文件只使用完整类名,因此,在代码中,当编译器遇到一个class名称时: 如果是完整类名,就直接根据完整类名查找这个class; 如果是简单类名,按下面的顺序依次查找: 查找当前package是否存在这个class; 查找import的包是否包含这个class; 查找java.lang包是否包含这个class。 如果按照上面的规则还无法确定类名,则编译报错。 我们来看一个例子: // Main.java package test; import java.text.Format; public class Main { public static void main(String[] args) { java.util.List list; // ok,使用完整类名 -> java.util.List Format format = null; // ok,使用import的类 -> java.text.Format String s = "hi"; // ok,使用java.lang包的String -> java.lang.String System.out.println(s); // ok,使用java.lang包的System -> java.lang.System MessageFormat mf = null; // 编译错误:无法找到MessageFormat: MessageFormat cannot be resolved to a type } } 因此,编写class的时候,编译器会自动帮我们做两个import动作: 默认自动import当前package的其他class; 默认自动import java.lang.*。 注意:自动导入的是java.lang包,但类似java.lang.reflect这些包仍需要手动导入。 如果有两个class名称相同,例如,mr.jun.Arrays和java.util.Arrays,那么只能import其中一个,另一个必须写完整类名。
作用域
作用域(scope),程序设计概念,通常来说,一段程序代码中所用到的名字并不总是有效/可用的,而限定这个名字的可用性的代码范围就是这个名字的作用域。 作用域的使用提高了程序逻辑的局部性,增强程序的可靠性,减少名字冲突。
public、protected、private这些修饰符可以用来限定访问作用域。
局部变量:在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。
在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。 package abc; public class Hello { void hi(String name) { // ① String s = name.toLowerCase(); // ② int len = s.length(); // ③ if (len < 10) { // ④ int p = 10 - len; // ⑤ for (int i=0; i<10; i++) { // ⑥ System.out.println(); // ⑦ } // ⑧ } // ⑨ } // ⑩ } 我们观察上面的hi()方法代码: 方法参数name是局部变量,它的作用域是整个方法,即①~⑩; 变量s的作用域是定义处到方法结束,即②~⑩; 变量len的作用域是定义处到方法结束,即③~⑩; 变量p的作用域是定义处到if块结束,即⑤~⑨; 变量i的作用域是for循环,即⑥~⑧。 使用局部变量时,应该尽可能把局部变量的作用域缩小,尽可能延后声明局部变量。
final修饰符:Java还提供了一个final修饰符。final与访问权限不冲突,它有很多作用。
用final修饰class可以阻止被继承
用final修饰method可以阻止被子类覆写
用final修饰field可以阻止被重新赋值
用final修饰局部变量可以阻止被重新赋值
classpath和jar
classpath是JVM用到的一个环境变量,它用来指示JVM如何搜索class。 因为Java是编译型语言,源码文件是.java,而编译后的.class文件才是真正可以被JVM执行的字节码。因此,JVM需要知道,如果要加载一个abc.xyz.Hello的类,应该去哪搜索对应的Hello.class文件。 所以,classpath就是一组目录的集合,它设置的搜索路径与操作系统相关。例如,在Windows系统上,用;分隔,带空格的目录用""括起来,可能长这样: C:\work\project1\bin;C:\shared;"D:\My Documents\project1\bin" 在Linux系统上,用:分隔,可能长这样: /usr/shared:/usr/local/bin:/home/liaoxuefeng/bin 现在我们假设classpath是.;C:\work\project1\bin;C:\shared,当JVM在加载abc.xyz.Hello这个类时,会依次查找: <当前目录>\abc\xyz\Hello.class C:\work\project1\bin\abc\xyz\Hello.class C:\shared\abc\xyz\Hello.class 注意到.代表当前目录。如果JVM在某个路径下找到了对应的class文件,就不再往后继续搜索。如果所有路径下都没有找到,就报错。 classpath的设定方法有两种: 在系统环境变量中设置classpath环境变量,不推荐; 在启动JVM时设置classpath变量,推荐。 我们强烈不推荐在系统环境变量中设置classpath,那样会污染整个系统环境。在启动JVM时设置classpath才是推荐的做法。实际上就是给java命令传入-classpath或-cp参数: java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello 或者使用-cp的简写: java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello 没有设置系统环境变量,也没有传入-cp参数,那么JVM默认的classpath为.,即当前目录: java abc.xyz.Hello 上述命令告诉JVM只在当前目录搜索Hello.class。 在IDE中运行Java程序,IDE自动传入的-cp参数是当前工程的bin目录和引入的jar包。 不要把任何Java核心库添加到classpath中!JVM根本不依赖classpath加载核心库! 更好的做法是,不要设置classpath!默认的当前目录.对于绝大多数情况都够用了。
模块
Java核心类
String
在Java中,String是一个引用类型,它本身也是一个class。但是,Java编译器对String有特殊处理,即可以直接用"..."来表示一个字符串。
创建:String s1 = "Hello"
在Java中,String是一个引用类型,它本身也是一个class。但是,Java编译器对String有特殊处理,即可以直接用"..."来表示一个字符串: String s1 = "Hello!"; 实际上字符串在String内部是通过一个char[]数组表示的,因此,按下面的写法也是可以的: String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'}); Java字符串的一个重要特点就是字符串不可变。这种不可变性是通过内部的private final char[]字段,以及没有任何修改char[]的方法实现的。
比较:s1.equals(s2)
两个字符串比较,必须总是使用equals()方法。 要忽略大小写比较,使用equalsIgnoreCase()方法。
搜索:s1.contain(s2) s1.indexOf(s2) / s1.lastIndexOf(s2) / s1.statsWith(s2) / s1.endWith(s2)
// 是否包含子串: "Hello".contains("ll"); // true "Hello".indexOf("l"); // 2 "Hello".lastIndexOf("l"); // 3 "Hello".startsWith("He"); // true "Hello".endsWith("lo"); // true
提取:s1.substring(index1, index2)
"Hello".substring(2); // "llo" "Hello".substring(2, 4); "ll"
去除首尾空白:s1.trim() / s1.strip()
使用trim()方法可以移除字符串首尾空白字符。空白字符包括空格,\t,\r,\n: " \tHello\r\n ".trim(); // "Hello" 注意:trim()并没有改变字符串的内容,而是返回了一个新字符串。 另一个strip()方法也可以移除字符串首尾空白字符。它和trim()不同的是,类似中文的空格字符\u3000也会被移除: "\u3000Hello\u3000".strip(); // "Hello" " Hello ".stripLeading(); // "Hello " " Hello ".stripTrailing(); // " Hello" String还提供了isEmpty()和isBlank()来判断字符串是否为空和空白字符串: "".isEmpty(); // true,因为字符串长度为0 " ".isEmpty(); // false,因为字符串长度不为0 " \n".isBlank(); // true,因为只包含空白字符 " Hello ".isBlank(); // false,因为包含非空白字符
替换子串:s1.replace(s2, s3) / s1.replaceAll(regex)
要在字符串中替换子串,有两种方法。一种是根据字符或字符串替换: String s = "hello"; s.replace('l', 'w'); // "hewwo",所有字符'l'被替换为'w' s.replace("ll", "~~"); // "he~~o",所有子串"ll"被替换为"~~" 另一种是通过正则表达式替换: String s = "A,,B;C ,D"; s.replaceAll("[\\,\\;\\s]+", ","); // "A,B,C,D" 上面的代码通过正则表达式,把匹配的子串统一替换为","。
分割:s1.split(regex)
要分割字符串,使用split()方法,并且传入的也是正则表达式: String s = "A,B,C,D"; String[] ss = s.split("\\,"); // {"A", "B", "C", "D"}
拼接:String.join("拼接符号", String[])
拼接字符串使用静态方法join(),它用指定的字符串连接字符串数组: String[] arr = {"A", "B", "C"}; String s = String.join("***", arr); // "A***B***C"
基本类型类型转换成String:String.valueOf(基本类型)
要把任意基本类型或引用类型转换为字符串,可以使用静态方法valueOf()。这是一个重载方法,编译器会根据参数自动选择合适的方法: String.valueOf(123); // "123" String.valueOf(45.67); // "45.67" String.valueOf(true); // "true" String.valueOf(new Object()); // 类似java.lang.Object@636be97c
String转换成基本类型:Integer.parseInt(s1) / Double.parseDouble(s1) / Boolean.parseBoolean(s1)
要把字符串转换为其他类型,就需要根据情况。 例如,把字符串转换为int类型: int n1 = Integer.parseInt("123"); // 123 int n2 = Integer.parseInt("ff", 16); // 按十六进制转换,255 把字符串转换为boolean类型: boolean b1 = Boolean.parseBoolean("true"); // true boolean b2 = Boolean.parseBoolean("FALSE"); // false
String -> char[]:char[ ] cs = s1.toCharArray()
char[] -> String:String s = new String(cs)
通过new String(char[])创建新的String实例时,它并不会直接引用传入的char[]数组,而是会复制一份,所以,修改外部的char[]数组不会影响String实例内部的char[]数组,因为这是两个不同的数组。
类型转换
编码:byte[] b1 = s1.getBytes("某种编码方式")
解码:String s1 = new String(b1, "某种编码方式")
编码-解码
在Java中,char类型实际上就是两个字节的Unicode编码。如果我们要手动把字符串转换成其他编码,可以这样做: byte[] b1 = "Hello".getBytes(); // 按系统默认编码转换,不推荐 byte[] b2 = "Hello".getBytes("UTF-8"); // 按UTF-8编码转换 byte[] b2 = "Hello".getBytes("GBK"); // 按GBK编码转换 byte[] b3 = "Hello".getBytes(StandardCharsets.UTF_8); // 按UTF-8编码转换 注意:转换编码后,就不再是char类型,而是byte类型表示的数组。 如果要把已知编码的byte[]转换为String,可以这样做: byte[] b = ... String s1 = new String(b, "GBK"); // 按GBK转换 String s2 = new String(b, StandardCharsets.UTF_8); // 按UTF-8转换 始终牢记:Java的String和char在内存中总是以Unicode编码表示。
StringBuilder
StringBuilder是可变对象,用来高效拼接字符串; StringBuilder可以支持链式操作,实现链式操作的关键是返回实例本身; StringBuffer是StringBuilder的线程安全版本,现在很少使用。
创建:StringBuilder sb = new StringBuilder(缓冲区大小);
写入字符串:sb.append(s1).sappend(s2)...
为了能高效拼接字符串,Java标准库提供了StringBuilder,它是一个可变对象,可以预分配缓冲区,这样,往StringBuilder中新增字符时,不会创建新的临时对象: StringBuilder sb = new StringBuilder(1024); for (int i = 0; i < 1000; i++) { sb.append(','); sb.append(i); } String s = sb.toString(); StringBuilder还可以进行链式操作: public class Main { public static void main(String[] args) { var sb = new StringBuilder(1024); sb.append("Mr ") .append("Bob") .append("!") .insert(0, "Hello, "); System.out.println(sb.toString()); } }
删除字符串:sb.delete(start_index, end_index)
转换:String s1 = sb.toString()
StringJoiner
StringJoiner负责类似用分隔符拼接数组的内容,还可以指定“开头”和“结尾”: public class Main { public static void main(String[] args) { String[] names = {"Bob", "Alice", "Grace"}; var sj = new StringJoiner(", ", "Hello ", "!"); for (String name : names) { sj.add(name); } System.out.println(sj.toString()); } } 运行结果: Hello Bob, Alice, Grace!
创建:var sj = new StringJoiner(分隔符, 开始符号, 末尾符号)
写入字符串:sj.add(s1)
转换:String s1 = sb.toString()
包装类型
基本类型 对应的引用类型 boolean java.lang.Boolean byte java.lang.Byte short java.lang.Short int java.lang.Integer long java.lang.Long float java.lang.Float double java.lang.Double char java.lang.Character 注意:自动装箱和自动拆箱只发生在编译阶段,目的是为了少写代码。 装箱和拆箱会影响代码的执行效率,因为编译后的class代码是严格区分基本类型和引用类型的。并且,自动拆箱执行时可能会报NullPointerException: public class Main { public static void main(String[] args) { Integer n = null; int i = n; } } 包装类型的比较必须使用equals();
自动装箱:Integer n = 100; // 编译器自动使用Integer.valueOf(int)
这种直接把int变为Integer的赋值写法,称为自动装箱(Auto Boxing)
自动拆箱:int x = n; // 编译器自动使用Integer.intValue()
把Integer变为int的赋值写法,称为自动拆箱(Auto Unboxing)。
进制转换:int x2 = Integer.parseInt("100", 16); // 256,因为按16进制解析
Integer类本身还提供了大量方法,例如,最常用的静态方法parseInt()可以把字符串解析成一个整数: int x1 = Integer.parseInt("100"); // 100 int x2 = Integer.parseInt("100", 16); // 256,因为按16进制解析 Integer还可以把整数格式化为指定进制的字符串: // Integer: public class Main { public static void main(String[] args) { System.out.println(Integer.toString(100)); // "100",表示为10进制 System.out.println(Integer.toString(100, 36)); // "2s",表示为36进制 System.out.println(Integer.toHexString(100)); // "64",表示为16进制 System.out.println(Integer.toOctalString(100)); // "144",表示为8进制 System.out.println(Integer.toBinaryString(100)); // "1100100",表示为2进制 } }
静态变量
Java的包装类型还定义了一些有用的静态变量 // boolean只有两个值true/false,其包装类型只需要引用Boolean提供的静态字段: Boolean t = Boolean.TRUE; Boolean f = Boolean.FALSE; // int可表示的最大/最小值: int max = Integer.MAX_VALUE; // 2147483647 int min = Integer.MIN_VALUE; // -2147483648 // long类型占用的bit和byte数量: int sizeOfLong = Long.SIZE; // 64 (bits) int bytesOfLong = Long.BYTES; // 8 (bytes)
Number
所有的整数和浮点数的包装类型都继承自Number,因此,可以非常方便地直接通过包装类型获取各种基本类型: // 向上转型为Number: Number num = new Integer(999); // 获取byte, int, long, float, double: byte b = num.byteValue(); int n = num.intValue(); long ln = num.longValue(); float f = num.floatValue(); double d = num.doubleValue();
JavaBean
在Java中,有很多class的定义都符合这样的规范: 若干private实例字段; 通过public方法来读写实例字段。 public class Person { private String name; private int age; public String getName() { return this.name; } public void setName(String name) { this.name = name; } public int getAge() { return this.age; } public void setAge(int age) { this.age = age; } } 如果读写方法符合以下这种命名规范: // 读方法: public Type getXyz() // 写方法: public void setXyz(Type value) 那么这种class被称为JavaBean: 上面的字段是xyz,那么读写方法名分别以get和set开头,并且后接大写字母开头的字段名Xyz,因此两个读写方法名分别是getXyz()和setXyz()。 boolean字段比较特殊,它的读方法一般命名为isXyz(): // 读方法: public boolean isChild() // 写方法: public void setChild(boolean value) 我们通常把一组对应的读方法(getter)和写方法(setter)称为属性(property)。 JavaBean是一种符合命名规范的class,它通过getter和setter来定义属性; 属性是一种通用的叫法,并非Java语法规定; 可以利用IDE快速生成getter和setter; 使用Introspector.getBeanInfo()可以获取属性列表。
枚举类
为了让编译器能自动检查某个值在枚举的集合内,并且,不同用途的枚举需要不同的类型来标记,不能混用,我们可以使用enum来定义枚举类: // enum public class Main { public static void main(String[] args) { Weekday day = Weekday.SUN; if (day == Weekday.SAT || day == Weekday.SUN) { System.out.println("Work at home!"); } else { System.out.println("Work at office!"); } } } enum Weekday { SUN, MON, TUE, WED, THU, FRI, SAT; } 和int定义的常量相比,使用enum定义枚举有如下好处: 首先,enum常量本身带有类型信息,即Weekday.SUN类型是Weekday,编译器会自动检查出类型错误。 其次,不可能引用到非枚举的值,因为无法通过编译。 最后,不同类型的枚举不能互相比较或者赋值,因为类型不符。 综上,这就使得编译器可以在编译期自动检查出所有可能的潜在错误。 小结 Java使用enum定义枚举类型,它被编译器编译为final class Xxx extends Enum { … }; 通过name()获取常量定义的字符串,注意不要使用toString(); 通过ordinal()返回常量定义的顺序(无实质意义); 可以为enum编写构造方法、字段和方法 enum的构造方法要声明为private,字段强烈建议声明为final; enum适合用在switch语句中。
为什么使用枚举类?
在Java中,我们可以通过static final来定义常量。例如,我们希望定义周一到周日这7个常量,可以用7个不同的int表示: public class Weekday { public static final int SUN = 0; public static final int MON = 1; public static final int TUE = 2; public static final int WED = 3; public static final int THU = 4; public static final int FRI = 5; public static final int SAT = 6; } 使用常量的时候,可以这么引用: if (day == Weekday.SAT || day == Weekday.SUN) { // TODO: work at home } 也可以把常量定义为字符串类型,例如,定义3种颜色的常量: public class Color { public static final String RED = "r"; public static final String GREEN = "g"; public static final String BLUE = "b"; } 使用常量的时候,可以这么引用: String color = ... if (Color.RED.equals(color)) { // TODO: } 无论是int常量还是String常量,使用这些常量来表示一组枚举值的时候,有一个严重的问题就是,编译器无法检查每个值的合理性。例如: if (weekday == 6 || weekday == 7) { if (tasks == Weekday.MON) { // TODO: } } 上述代码编译和运行均不会报错,但存在两个问题: 注意到Weekday定义的常量范围是0~6,并不包含7,编译器无法检查不在枚举中的int值; 定义的常量仍可与其他变量比较,但其用途并非是枚举星期值。
创建:enum ClassName{A, B, C...}
比较:==
使用enum定义的枚举类是一种引用类型。前面我们讲到,引用类型比较,要使用equals()方法,如果使用==比较,它比较的是两个引用类型的变量是否是同一个对象。因此,引用类型比较,要始终使用equals()方法,但enum类型可以例外。 这是因为enum类型的每个常量在JVM中只有一个唯一实例,所以可以直接用==比较: if (day == Weekday.FRI) { // ok! } if (day.equals(Weekday.SUN)) { // ok, but more code! }
返回常量名:String s = ClassName.A.name()
返回定义常量的序号:int n = ClassName.A.ordinal()
ordinal() 返回定义的常量的顺序,从0开始计数,例如: int n = Weekday.MON.ordinal(); // 1 改变枚举常量定义的顺序就会导致ordinal()返回值发生变化。例如: public enum Weekday { SUN, MON, TUE, WED, THU, FRI, SAT; } 和 public enum Weekday { MON, TUE, WED, THU, FRI, SAT, SUN; } 的ordinal就是不同的。如果在代码中编写了类似if(x.ordinal()==1)这样的语句,就要保证enum的枚举顺序不能变。新增的常量必须放在最后。 有些童鞋会想,Weekday的枚举常量如果要和int转换,使用ordinal()不是非常方便?比如这样写: String task = Weekday.MON.ordinal() + "/ppt"; saveToFile(task); 但是,如果不小心修改了枚举的顺序,编译器是无法检查出这种逻辑错误的。要编写健壮的代码,就不要依靠ordinal()的返回值。因为enum本身是class,所以我们可以定义private的构造方法,并且,给每个枚举常量添加字段: public class Main { public static void main(String[] args) { Weekday day = Weekday.SUN; if (day.dayValue == 6 || day.dayValue == 0) { System.out.println("Work at home!"); } else { System.out.println("Work at office!"); } } } enum Weekday { MON(1), TUE(2), WED(3), THU(4), FRI(5), SAT(6), SUN(0); public final int dayValue; private Weekday(int dayValue) { this.dayValue = dayValue; } }
增加可读性:toString()
默认情况下,对枚举常量调用toString()会返回和name()一样的字符串。但是,toString()可以被覆写,而name()则不行。我们可以给Weekday添加toString()方法: // enum public class Main { public static void main(String[] args) { Weekday day = Weekday.SUN; if (day.dayValue == 6 || day.dayValue == 0) { System.out.println("Today is " + day + ". Work at home!"); } else { System.out.println("Today is " + day + ". Work at office!"); } } } enum Weekday { MON(1, "星期一"), TUE(2, "星期二"), WED(3, "星期三"), THU(4, "星期四"), FRI(5, "星期五"), SAT(6, "星期六"), SUN(0, "星期日"); public final int dayValue; private final String chinese; private Weekday(int dayValue, String chinese) { this.dayValue = dayValue; this.chinese = chinese; } @Override public String toString() { return this.chinese; } } 上述代码的运行结果如下: Today is 星期日. Work at home! 覆写toString()的目的是在输出时更有可读性。 注意:判断枚举常量的名字,要始终使用name()方法,绝不能调用toString()!
枚举:switch
枚举类可以应用在switch语句中。因为枚举类天生具有类型信息和有限个枚举常量,所以比int、String类型更适合用在switch语句中: // switch public class Main { public static void main(String[] args) { Weekday day = Weekday.SUN; switch(day) { case MON: case TUE: case WED: case THU: case FRI: System.out.println("Today is " + day + ". Work at office!"); break; case SAT: case SUN: System.out.println("Today is " + day + ". Work at home!"); break; default: throw new RuntimeException("cannot process " + day); } } } enum Weekday { MON, TUE, WED, THU, FRI, SAT, SUN; } 上述代码的运行结果如下: Today is SUN. Work at home! 加上default语句,可以在漏写某个枚举常量时自动报错,从而及时发现错误。
记录类
record关键字
使用String、Integer等类型的时候,这些类型都是不变类,一个不变类具有以下特点: 定义class时使用final,无法派生子类; 每个字段使用final,保证创建实例后无法修改任何字段 从Java 14开始,引入了新的Record类。我们定义Record类时,使用关键字record。把上述Point类改写为Record类(不变类),代码如下: // Record public class Main { public static void main(String[] args) { Point p = new Point(123, 456); System.out.println(p.x()); System.out.println(p.y()); System.out.println(p); } } public record Point(int x, int y) {} 仔细观察Point的定义: public record Point(int x, int y) {} 把上述定义改写为class,相当于以下代码: public final class Point extends Record { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } public int x() { return this.x; } public int y() { return this.y; } public String toString() { return String.format("Point[x=%s, y=%s]", x, y); } public boolean equals(Object o) { ... } public int hashCode() { ... } } 除了用final修饰class以及每个字段外,编译器还自动为我们创建了构造方法,和字段名同名的方法,以及覆写toString()、equals()和hashCode()方法。换句话说,使用record关键字,可以一行写出一个不变类。和enum类似,我们自己不能直接从Record派生,只能通过record关键字由编译器实现继承。 构造方法 编译器默认按照record声明的变量顺序自动创建一个构造方法,并在方法内给字段赋值。那么问题来了,如果我们要检查参数,应该怎么办? 假设Point类的x、y不允许负数,我们就得给Point的构造方法加上检查逻辑: public record Point(int x, int y) { public Point { if (x < 0 || y < 0) { throw new IllegalArgumentException(); } } } 注意到方法public Point {...}被称为Compact Constructor,它的目的是让我们编写检查逻辑,编译器最终生成的构造方法如下: public final class Point extends Record { public Point(int x, int y) { // 这是我们编写的Compact Constructor: if (x < 0 || y < 0) { throw new IllegalArgumentException(); } // 这是编译器继续生成的赋值代码: this.x = x; this.y = y; } ... } 作为record的Point仍然可以添加静态方法。一种常用的静态方法是of()方法,用来创建Point: public record Point(int x, int y) { public static Point of() { return new Point(0, 0); } public static Point of(int x, int y) { return new Point(x, y); } } 这样我们可以写出更简洁的代码: var z = Point.of(); var p = Point.of(123, 456); 从Java 14开始,提供新的record关键字,可以非常方便地定义Data Class: 使用record定义的是不变类; 可以编写Compact Constructor对参数进行验证; 可以定义静态方法。
BigInteger
java.math.BigInteger就是用来表示任意大小的整数。BigInteger内部用一个int[]数组来模拟一个非常大的整数:
创建:BigInteger a = new BigInteger("1234567890")
运算:a.add(b) / a.subtract(b) / a.multiply(b) / a.mod(b) 等
对BigInteger做运算的时候,只能使用实例方法,例如,加法运算: BigInteger i1 = new BigInteger("1234567890"); BigInteger i2 = new BigInteger("12345678901234567890"); BigInteger sum = i1.add(i2); // 12345678902469135780
转换:比如longValueExact()
和long型整数运算比,BigInteger不会有范围限制,但缺点是速度比较慢。 也可以把BigInteger转换成long型: BigInteger i = new BigInteger("123456789000"); System.out.println(i.longValue()); // 123456789000 System.out.println(i.multiply(i).longValueExact()); // java.lang.ArithmeticException: BigInteger out of long range 使用longValueExact()方法时,如果超出了long型的范围,会抛出ArithmeticException。 BigInteger和Integer、Long一样,也是不可变类,并且也继承自Number类。因为Number定义了转换为基本类型的几个方法: 转换为byte:byteValue() 转换为short:shortValue() 转换为int:intValue() 转换为long:longValue() 转换为float:floatValue() 转换为double:doubleValue() 因此,通过上述方法,可以把BigInteger转换成基本类型。如果BigInteger表示的范围超过了基本类型的范围,转换时将丢失高位信息,即结果不一定是准确的。如果需要准确地转换成基本类型,可以使用intValueExact()、longValueExact()等方法,在转换时如果超出范围,将直接抛出ArithmeticException异常。
BigDecimal
BigDecimal可以表示一个任意大小且精度完全准确的浮点数,常用于财务计算; 比较BigDecimal的值是否相等,必须使用compareTo()而不能使用equals()。
创建:BigDecimal bd = new BigDecimal("123.4567")
小数位数:bd.scale() // 4,四位小数
去掉末尾零:bd.stripTrailingZeros()
通过BigDecimal的stripTrailingZeros()方法,可以将一个BigDecimal格式化为一个相等的,但去掉了末尾0的BigDecimal: BigDecimal d1 = new BigDecimal("123.4500"); BigDecimal d2 = d1.stripTrailingZeros(); System.out.println(d1.scale()); // 4 System.out.println(d2.scale()); // 2,因为去掉了00 BigDecimal d3 = new BigDecimal("1234500"); BigDecimal d4 = d3.stripTrailingZeros(); System.out.println(d3.scale()); // 0 System.out.println(d4.scale()); // -2 如果一个BigDecimal的scale()返回负数,例如,-2,表示这个数是个整数,并且末尾有2个0。
运算:bd.function(bd2)
对BigDecimal做加、减、乘时,精度不会丢失,但是做除法时,存在无法除尽的情况,这时,就必须指定精度以及如何进行截断: BigDecimal d1 = new BigDecimal("123.456"); BigDecimal d2 = new BigDecimal("23.456789"); BigDecimal d3 = d1.divide(d2, 10, RoundingMode.HALF_UP); // 保留10位小数并四舍五入 BigDecimal d4 = d1.divide(d2); // 报错:ArithmeticException,因为除不尽 还可以对BigDecimal做除法的同时求余数: import java.math.BigDecimal; public class Main { public static void main(String[] args) { BigDecimal n = new BigDecimal("12.345"); BigDecimal m = new BigDecimal("0.12"); BigDecimal[] dr = n.divideAndRemainder(m); System.out.println(dr[0]); // 102 System.out.println(dr[1]); // 0.105 } } 调用divideAndRemainder()方法时,返回的数组包含两个BigDecimal,分别是商和余数,其中商总是整数,余数不会大于除数。我们可以利用这个方法判断两个BigDecimal是否是整数倍数: BigDecimal n = new BigDecimal("12.75"); BigDecimal m = new BigDecimal("0.15"); BigDecimal[] dr = n.divideAndRemainder(m); if (dr[1].signum() == 0) { // n是m的整数倍 }
截断
可以对一个BigDecimal设置它的scale,如果精度比原始值低,那么按照指定的方法进行四舍五入或者直接截断: import java.math.BigDecimal; import java.math.RoundingMode; public class Main { public static void main(String[] args) { BigDecimal d1 = new BigDecimal("123.456789"); BigDecimal d2 = d1.setScale(4, RoundingMode.HALF_UP); // 四舍五入,123.4568 BigDecimal d3 = d1.setScale(4, RoundingMode.DOWN); // 直接截断,123.4567 System.out.println(d2); System.out.println(d3); } } 运行结果: 123.4568 123.4567
比较:bd.equals(bd2)
在比较两个BigDecimal的值是否相等时,要特别注意,使用equals()方法不但要求两个BigDecimal的值相等,还要求它们的scale()相等: BigDecimal d1 = new BigDecimal("123.456"); BigDecimal d2 = new BigDecimal("123.45600"); System.out.println(d1.equals(d2)); // false,因为scale不同 System.out.println(d1.equals(d2.stripTrailingZeros())); // true,因为d2去除尾部0后scale变为2 System.out.println(d1.compareTo(d2)); // 0 必须使用compareTo()方法来比较,它根据两个值的大小分别返回负数、正数和0,分别表示小于、大于和等于。
常用工具类
数学计算:Math
Math类就是用来进行数学计算的,它提供了大量的静态方法来便于我们实现数学计算: 求绝对值: Math.abs(-100); // 100 Math.abs(-7.8); // 7.8 取最大或最小值: Math.max(100, 99); // 100 Math.min(1.2, 2.3); // 1.2 计算xy次方: Math.pow(2, 10); // 2的10次方=1024 计算√x: Math.sqrt(2); // 1.414... 计算ex次方: Math.exp(2); // 7.389... 计算以e为底的对数: Math.log(4); // 1.386... 计算以10为底的对数: Math.log10(100); // 2 三角函数: Math.sin(3.14); // 0.00159... Math.cos(3.14); // -0.9999... Math.tan(3.14); // -0.0015... Math.asin(1.0); // 1.57079... Math.acos(1.0); // 0.0 Math还提供了几个数学常量: double pi = Math.PI; // 3.14159... double e = Math.E; // 2.7182818... Math.sin(Math.PI / 6); // sin(π/6) = 0.5 生成一个随机数x,x的范围是0 <= x < 1: Math.random(); // 0.53907... 每次都不一样 如果我们要生成一个区间在[MIN, MAX)的随机数,可以借助Math.random()实现,计算如下: // 区间在[MIN, MAX)的随机数 public class Main { public static void main(String[] args) { double x = Math.random(); // x的范围是[0,1) double min = 10; double max = 50; double y = x * (max - min) + min; // y的范围是[10,50) long n = (long) y; // n的范围是[10,50)的整数 System.out.println(y); System.out.println(n); } } 有些童鞋可能注意到Java标准库还提供了一个StrictMath,它提供了和Math几乎一模一样的方法。这两个类的区别在于,由于浮点数计算存在误差,不同的平台(例如x86和ARM)计算的结果可能不一致(指误差不同),因此,StrictMath保证所有平台计算结果都是完全相同的,而Math会尽量针对平台优化计算速度,所以,绝大多数情况下,使用Math就足够了。
生成伪造随机数:Random
Random用来创建伪随机数。所谓伪随机数,是指只要给定一个初始的种子,产生的随机数序列是完全一样的。 要生成一个随机数,可以使用nextInt()、nextLong()、nextFloat()、nextDouble(): Random r = new Random(); r.nextInt(); // 2071575453,每次都不一样 r.nextInt(10); // 5,生成一个[0,10)之间的int r.nextLong(); // 8811649292570369305,每次都不一样 r.nextFloat(); // 0.54335...生成一个[0,1)之间的float r.nextDouble(); // 0.3716...生成一个[0,1)之间的double
生成安全随机数:SecureRandom
SecureRandom就是用来创建安全的随机数的: SecureRandom sr = new SecureRandom(); System.out.println(sr.nextInt(100)); SecureRandom无法指定种子,它使用RNG(random number generator)算法。JDK的SecureRandom实际上有多种不同的底层实现,有的使用安全随机种子加上伪随机数算法来产生安全的随机数,有的使用真正的随机数生成器。实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器: import java.util.Arrays; import java.security.SecureRandom; import java.security.NoSuchAlgorithmException; public class Main { public static void main(String[] args) { SecureRandom sr = null; try { sr = SecureRandom.getInstanceStrong(); // 获取高强度安全随机数生成器 } catch (NoSuchAlgorithmException e) { sr = new SecureRandom(); // 获取普通的安全随机数生成器 } byte[] buffer = new byte[16]; sr.nextBytes(buffer); // 用安全随机数填充buffer System.out.println(Arrays.toString(buffer)); } } 运行结果(每次不一样): [-97, -98, -13, -102, -4, 119, 37, -100, 114, -15, -122, -56, 22, 5, 70, -68] SecureRandom的安全性是通过操作系统提供的安全的随机种子来生成随机数。这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”。 在密码学中,安全的随机数非常重要。如果使用不安全的伪随机数,所有加密体系都将被攻破。因此,时刻牢记必须使用SecureRandom来产生安全的随机数。 需要使用安全随机数的时候,必须使用SecureRandom,绝不能使用Random!
Collection
Java标准库自带的java.util包提供了集合类:Collection,它是除Map外所有其他集合类的根接口。Java的java.util包主要提供了以下三种类型的集合: List:一种有序列表的集合,例如,按索引排列的Student的List; Set:一种保证没有重复元素的集合,例如,所有无重复名称的Student的Set; Map:一种通过键值(key-value)查找的映射表集合,例如,根据Student的name查找对应Student的Map。 Java集合的设计有几个特点: 实现了接口和实现类相分离,例如,有序表的接口是List,具体的实现类有ArrayList,LinkedList等。 支持泛型,我们可以限制在一个集合中只能放入同一种数据类型的元素,例如:List<String> list = new ArrayList<>(); // 只能放入String类型。 Java访问集合总是通过统一的方式——迭代器(Iterator)来实现,它最明显的好处在于无需知道集合内部元素是按什么方式存储的。
List
List是最基础的一种集合:它是一种有序链表。List的行为和数组几乎完全相同:List内部按照放入元素的先后顺序存放,每个元素都可以通过索引确定自己的位置,List的索引和数组一样,从0开始。
创建:
List<E> list = new ArrayList<>()
List<E> list = new LinkedList<>()
List<E> list = List.of(E e1, E e2, E e3)
除了使用ArrayList和LinkedList,我们还可以通过List接口提供的of()方法,根据给定元素快速创建List: List<Integer> list = List.of(1, 2, 5); 但是List.of()方法不接受null值,如果传入null,会抛出NullPointerException异常。
List特点:List接口允许我们添加重复的元素,即List内部的元素可以重复。
使用List时,我们要关注List接口的规范。List接口允许我们添加重复的元素,即List内部的元素可以重复: import java.util.ArrayList; import java.util.List; public class Main { public static void main(String[] args) { List<String> list = new ArrayList<>(); list.add("apple"); // size=1 list.add("pear"); // size=2 list.add("apple"); // 允许重复添加元素,size=3 System.out.println(list.size()); } } 运行结果: 3 List还允许添加null: import java.util.ArrayList; import java.util.List; public class Main { public static void main(String[] args) { List<String> list = new ArrayList<>(); list.add("apple"); // size=1 list.add(null); // size=2 list.add("pear"); // size=3 String second = list.get(1); // null System.out.println(second); } } 运行结果: null
接口实现:ArrayList和LinkedList
ArrayList在内部使用了数组来存储所有元素,另一种LinkedList通过“链表”也实现了List接口。在LinkedList中,它的内部每个元素都指向下一个元素,通常情况下,我们总是优先使用ArrayList。 比较一下ArrayList和LinkedList: ArrayList LinkedList 获取指定元素 速度很快 需要从头开始查找元素 添加元素到末尾 速度很快 速度很快 在指定位置添加/删除 需要移动元素 不需要移动元素 内存占用 少 较大
在末尾添加一个元素:void add(E e)
在指定索引添加一个元素:void add(int index, E e)
删除指定索引的元素:Object o remove(int index)
删除某个元素:boolean remove(Object e)
获取指定索引的元素:E get(int index)
获取指定元素的索引:int indexOf(object o)
int indexOf(Object o)方法可以返回某个元素的索引,如果元素不存在,就返回-1。
获取链表大小(包含元素的个数):int size()
判断是否存在指定元素:boolean contains(Object o)
List接口方法
遍历:
迭代器访问:for (Iterator<String> it = list.iterator(); it.hasNext(); )
要始终坚持使用迭代器Iterator来访问List。Iterator本身也是一个对象,但它是由List的实例调用iterator()方法的时候创建的。Iterator对象知道如何遍历一个List,并且不同的List类型,返回的Iterator对象实现也是不同的,但总是具有最高的访问效率。 Iterator对象有两个方法:boolean hasNext()判断是否有下一个元素,E next()返回下一个元素。因此,使用Iterator遍历List代码如下: import java.util.Iterator; import java.util.List; public class Main { public static void main(String[] args) { List<String> list = List.of("apple", "pear", "banana"); for (Iterator<String> it = list.iterator(); it.hasNext(); ) { String s = it.next(); System.out.println(s); } } } 运行结果: apple pear banana
for each循环:for (String s : list)
使用Iterator访问List的代码比使用索引更复杂。但是,要记住,通过Iterator遍历List永远是最高效的方式。并且,由于Iterator遍历是如此常用,所以,Java的for each循环本身就可以帮我们使用Iterator遍历。把上面的代码再改写如下: import java.util.List; public class Main { public static void main(String[] args) { List<String> list = List.of("apple", "pear", "banana"); for (String s : list) { System.out.println(s); } } } 运行结果: apple pear banana
List转换成Array:T[ ] array = list.toArray(new T[list.size()])
给toArray(T[])传入一个类型相同的Array,List内部自动把元素复制到传入的Array中: import java.util.List; public class Main { public static void main(String[] args) { List<Integer> list = List.of(12, 34, 56); Integer[] array = list.toArray(new Integer[3]); for (Integer n : array) { System.out.println(n); } } } 注意到这个toArray(T[])方法的泛型参数<T>并不是List接口定义的泛型参数<E>,所以,我们实际上可以传入其他类型的数组,例如我们传入Number类型的数组,返回的仍然是Number类型: import java.util.List; public class Main { public static void main(String[] args) { List<Integer> list = List.of(12, 34, 56); Number[] array = list.toArray(new Number[3]); for (Number n : array) { System.out.println(n); } } } 但是,如果我们传入类型不匹配的数组,例如,String[]类型的数组,由于List的元素是Integer,所以无法放入String数组,这个方法会抛出ArrayStoreException。 如果我们传入的数组大小和List实际的元素个数不一致怎么办?根据List接口的文档,我们可以知道: 如果传入的数组不够大,那么List内部会创建一个新的刚好够大的数组,填充后返回; 如果传入的数组比List元素还要多,那么填充完元素后,剩下的数组元素一律填充null。 实际上,最常用的是传入一个“恰好”大小的数组: Integer[] array = list.toArray(new Integer[list.size()]); 最后一种更简洁的写法是通过List接口定义的T[] toArray(IntFunction<T[]> generator)方法: Integer[] array = list.toArray(Integer[]::new);
Array转换成List:List<Integer> list = List.of(array)
把Array变为List就简单多了,通过List.of(T...)方法最简单: Integer[] array = { 1, 2, 3 }; List<Integer> list = List.of(array); 对于JDK 11之前的版本,可以使用Arrays.asList(T...)方法把数组转换成List。 要注意的是,返回的List不一定就是ArrayList或者LinkedList,因为List只是一个接口,如果我们调用List.of(),它返回的是一个只读List: import java.util.List; public class Main { public static void main(String[] args) { List<Integer> list = List.of(12, 34, 56); list.add(999); // UnsupportedOperationException } } 对只读List调用add()、remove()方法会抛出UnsupportedOperationException。
List与Array转换
List正常运行的保证
要正确使用List的contains()、indexOf()这些方法,放入的实例必须正确覆写equals()方法,否则,放进去的实例,查找不到。我们之所以能正常放入String、Integer这些对象,是因为Java标准库定义的这些类已经正确实现了equals()方法。 我们以Person对象为例,测试一下: import java.util.List; public class Main { public static void main(String[] args) { List<Person> list = List.of( new Person("Xiao Ming"), new Person("Xiao Hong"), new Person("Bob") ); System.out.println(list.contains(new Person("Bob"))); // false } } class Person { String name; public Person(String name) { this.name = name; } } 运行结果: false 不出意外,虽然放入了new Person("Bob"),但是用另一个new Person("Bob")查询不到,原因就是Person类没有覆写equals()方法。
目的:正确使用List的contains()、indexOf()这些方法,必须覆写equals()方法。
equals编写方法:
总结一下equals()方法的正确编写方法: 先确定实例“相等”的逻辑,即哪些字段相等,就认为实例相等; 用instanceof判断传入的待比较的Object是不是当前类型,如果是,继续比较,否则,返回false; 对引用类型用Objects.equals()比较,对基本类型直接用==比较。 使用Objects.equals()比较两个引用类型是否相等的目的是省去了判断null的麻烦。两个引用类型都是null时它们也是相等的。 如果不调用List的contains()、indexOf()这些方法,那么放入的元素就不需要实现equals()方法。 例子: import java.util.List; import java.util.Objects; public class Main { public static void main(String[] args) { List<Person> list = List.of( new Person("Xiao", "Ming", 18), new Person("Xiao", "Hong", 25), new Person("Bob", "Smith", 20) ); boolean exist = list.contains(new Person("Bob", "Smith", 20)); System.out.println(exist ? "测试成功!" : "测试失败!"); } } class Person { String firstName; String lastName; int age; public Person(String firstName, String lastName, int age) { this.firstName = firstName; this.lastName = lastName; this.age = age; } public boolean equals(Object o) { if (o instanceof Person) { Person p = (Person) o; return Objects.equals(this.firstName, p.firstName) && Objects.equals(this.lastName, p.lastName) && this.age== p.age; } return false; } }
先确定实例“相等”的逻辑,即哪些字段相等,就认为实例相等;
用instanceof判断传入的待比较的Object是不是当前类型,如果是,继续比较,否则,返回false;
对引用类型用Objects.equals()比较,对基本类型直接用==比较。
Map
Map这种键值(key-value)映射表的数据结构,作用就是能高效通过key快速查找value(元素)。
创建:Map<String, Integer> map = new HashMap<>()
HashMap 是一个散列表,它存储的内容是键值对(key-value)映射。 该类实现了Map接口,根据键的HashCode值存储数据,具有很快的访问速度,最多允许一条记录的键为null,不支持线程同步。
Map特点:Map中不存在重复的key,因为放入相同的key,只会把原有的key-value对应的value给替换掉。
如果我们在存储Map映射关系的时候,对同一个key调用两次put()方法,分别放入不同的value,会有什么问题呢?例如: import java.util.HashMap; import java.util.Map; public class Main { public static void main(String[] args) { Map<String, Integer> map = new HashMap<>(); map.put("apple", 123); map.put("pear", 456); System.out.println(map.get("apple")); // 123 map.put("apple", 789); // 再次放入apple作为key,但value变为789 System.out.println(map.get("apple")); // 789 } } 运行结果: 123 789 重复放入key-value并不会有任何问题,但是一个key只能关联一个value。在上面的代码中,一开始我们把key对象"apple"映射到Integer对象123,然后再次调用put()方法把"apple"映射到789,这时,原来关联的value对象123就被“冲掉”了。实际上,put()方法的签名是V put(K key, V value),如果放入的key已经存在,put()方法会返回被删除的旧的value,否则,返回null。 始终牢记:Map中不存在重复的key,因为放入相同的key,只会把原有的key-value对应的value给替换掉。
增加元素:map.put("apple", 123)
判断是否存在指定key:boolean containsKey(K key)
获取key对应的值:V get(K key),如果key不存在,则返回null。
Map接口方法
遍历
Map和List不同的是,Map存储的是key-value的映射关系,并且,它不保证顺序。在遍历的时候,遍历的顺序既不一定是put()时放入的key的顺序,也不一定是key的排序顺序。使用Map时,任何依赖顺序的逻辑都是不可靠的。以HashMap为例,假设我们放入"A","B","C"这3个key,遍历的时候,每个key会保证被遍历一次且仅遍历一次,但顺序完全没有保证,甚至对于不同的JDK版本,相同的代码遍历的输出顺序都是不同的! 遍历Map时,不可假设输出的key是有序的! 例子: 请编写一个根据name查找score的程序,并利用Map充当缓存,以提高查找效率: import java.util.*; public class HelloWorld { public static void main(String[] args) { List<Student> list = List.of( new Student("Bob", 78), new Student("Alice", 85), new Student("Brush", 66), new Student("Newton", 99)); var holder = new Students(list); System.out.println(holder.getScore("Bob") == 78 ? "测试成功!" : "测试失败!"); System.out.println(holder.getScore("Alice") == 85 ? "测试成功!" : "测试失败!"); System.out.println(holder.getScore("Tom") == -1 ? "测试成功!" : "测试失败!"); } } class Students { List<Student> list; Map<String, Integer> cache; Students(List<Student> list) { this.list = list; cache = new HashMap<>(); } /** * 根据name查找score,找到返回score,未找到返回-1 */ int getScore(String name) { // 先在Map中查找: Integer score = this.cache.get(name); //注意这里用了 Integer 以便解决 null 的情形 if (score == null) { //若在 cache(HashMap) 中找不到,再使用 findInList 在 List 中查找,速度较慢 score = this.findInList(name); if(score != null) { //name 在 List 中,更新 score 并将结果存入 cache cache.put(name, score); } } return score == null ? -1 : score.intValue(); } Integer findInList(String name) { for (var ss : this.list) { if (ss.name.equals(name)) { return ss.score; } } return null; } } class Student { String name; int score; Student(String name, int score) { this.name = name; this.score = score; } } 运行结果: 测试成功! 测试成功! 测试成功!
entrySet方法:for (Map.Entry<String, Integer> entry : map.entrySet())
遍历key和value可以使用for each循环遍历Map对象的entrySet()集合,它包含每一个key-value映射: import java.util.HashMap; import java.util.Map; public class Main { public static void main(String[] args) { Map<String, Integer> map = new HashMap<>(); map.put("apple", 123); map.put("pear", 456); map.put("banana", 789); for (Map.Entry<String, Integer> entry : map.entrySet()) { String key = entry.getKey(); Integer value = entry.getValue(); System.out.println(key + " = " + value); } } } 运行结果: banana = 789 apple = 123 pear = 456
for each循环: (String key : map.keySet())
对Map来说,要遍历key可以使用for each循环遍历Map实例的keySet()方法返回的Set集合,它包含不重复的key的集合: import java.util.HashMap; import java.util.Map; public class Main { public static void main(String[] args) { Map<String, Integer> map = new HashMap<>(); map.put("apple", 123); map.put("pear", 456); map.put("banana", 789); for (String key : map.keySet()) { Integer value = map.get(key); System.out.println(key + " = " + value); } } } 运行结果: banana = 789 apple = 123 pear = 456
Map正常运行的保证:
逻辑: 相同的key应该从Map获取相同的value -> 相同key计算出的对应的value的位置应该在Map存储中是同一个索引位置 -> 相同key的hashCode()值一样 而如何判断两个key是否一样?作为key的对象必须正确覆写equals()方法。我们经常使用String作为key,因为String已经正确覆写了equals()方法。但如果我们放入的key是一个自己写的类,就必须保证正确覆写了equals()方法。 又如何计算hashCode()?使用Objects.hash()来计算,且计算对象是且只能是equals()用到的用于key比较的每一个字段。 例如使用使用自定义类Person作为Map的key: import java.util.HashMap; import java.util.Map; import java.util.Objects; public class HelloWorld { public static void main(String[] args) { Map<Person, Integer> map = new HashMap<>(); Person p1 = new Person("Zhang", "San", 18); map.put(p1, 98); Person p2 = new Person("Zhang", "San", 18); Integer score = map.get(p2); System.out.println(score); System.out.println(p1 == p2); System.out.println(p1.equals(p2)); } } class Person { String firstName; String lastName; int age; public Person(String firstName, String lastName, int age) { this.firstName = firstName; this.lastName = lastName; this.age = age; } public boolean equals(Object o) { if (o instanceof Person) { Person p = (Person) o; return Objects.equals(this.firstName, p.firstName) && Objects.equals(this.lastName, p.lastName) && this.age== p.age; } return false; } @Override public int hashCode() { return Objects.hash(firstName, lastName, age); } }
正确覆写作为key对象的equals方法
为什么:因为当两个具有相同内容但不同对象的key计算出的对应的value的位置应该在Map存储中是同一个索引位置,而在Map的内部,对key内容做比较是通过equals()实现的,所以作为key的对象必须正确覆写equals()方法。
因为当两个具有相同内容但不同对象的key计算出的对应的value的位置应该在Map存储中是同一个索引位置,而在Map的内部,对key内容做比较是通过equals()实现的,所以作为key的对象必须正确覆写equals()方法。 举例: 我们知道Map是一种键-值(key-value)映射表,可以通过key快速查找对应的value。 以HashMap为例,观察下面的代码: Map<String, Person> map = new HashMap<>(); map.put("a", new Person("Xiao Ming")); map.put("b", new Person("Xiao Hong")); map.put("c", new Person("Xiao Jun")); map.get("a"); // Person("Xiao Ming") map.get("x"); // null HashMap之所以能根据key直接拿到value,原因是它内部通过空间换时间的方法,用一个大数组存储所有value,并根据key直接计算出value应该存储在哪个索引。 如果key的值为"a",计算得到的索引总是1,因此返回value为Person("Xiao Ming"),如果key的值为"b",计算得到的索引总是5,因此返回value为Person("Xiao Hong"),这样,就不必遍历整个数组,即可直接读取key对应的value。 当我们使用key存取value的时候,就会引出一个问题: 我们放入Map的key是字符串"a",但是,当我们获取Map的value时,传入的变量不一定就是放入的那个key对象。 换句话讲,两个key应该是内容相同,但不一定是同一个对象。测试代码如下: import java.util.HashMap; import java.util.Map; public class Main { public static void main(String[] args) { String key1 = "a"; Map<String, Integer> map = new HashMap<>(); map.put(key1, 123); String key2 = new String("a"); map.get(key2); // 123 System.out.println(key1 == key2); // false System.out.println(key1.equals(key2)); // true } } 运行结果: false true 因为在Map的内部,对key内容做比较是通过equals()实现的,这一点和List查找元素需要正确覆写equals()是一样的,即正确使用Map必须保证:作为key的对象必须正确覆写equals()方法。 我们经常使用String作为key,因为String已经正确覆写了equals()方法。但如果我们放入的key是一个自己写的类,就必须保证正确覆写了equals()方法。
怎么做:与List的equals覆写方法一样。
正确覆写作为key对象的hashCode方法
为什么:通过key计算索引的方式就是调用key对象的hashCode()方法,它返回一个int整数。HashMap正是通过这个方法直接定位key对应的value的索引,继而直接返回value。
怎么做:hashCode()方法要严格遵循以下规范: 如果两个对象内容相等,则两个对象的hashCode()必须相等(正确性); 如果两个对象内容不相等,则两个对象的hashCode()尽量不要相等(效率考虑)。
EnumMap
如果作为key的对象是enum类型,那么,还可以使用Java集合库提供的一种EnumMap,它在内部以一个非常紧凑的数组存储value,并且根据enum类型的key直接定位到内部数组的索引,并不需要计算hashCode(),不但效率最高,而且没有额外的空间浪费。 我们以DayOfWeek这个枚举类型为例,为它做一个“翻译”功能: import java.time.DayOfWeek; import java.util.*; public class Main { public static void main(String[] args) { Map<DayOfWeek, String> map = new EnumMap<>(DayOfWeek.class); map.put(DayOfWeek.MONDAY, "星期一"); map.put(DayOfWeek.TUESDAY, "星期二"); map.put(DayOfWeek.WEDNESDAY, "星期三"); map.put(DayOfWeek.THURSDAY, "星期四"); map.put(DayOfWeek.FRIDAY, "星期五"); map.put(DayOfWeek.SATURDAY, "星期六"); map.put(DayOfWeek.SUNDAY, "星期日"); System.out.println(map); System.out.println(map.get(DayOfWeek.MONDAY)); } } 运行结果: {MONDAY=星期一, TUESDAY=星期二, WEDNESDAY=星期三, THURSDAY=星期四, FRIDAY=星期五, SATURDAY=星期六, SUNDAY=星期日} 星期一 使用EnumMap的时候,我们总是用Map接口来引用它,因此,实际上把HashMap和EnumMap互换,在客户端看来没有任何区别。
目的:如果Map的key是enum类型,推荐使用EnumMap,既保证速度,也不浪费空间。使用EnumMap的时候,根据面向抽象编程的原则,应持有Map接口。
创建:Map<枚举类的类名, String> map = new EnumMap<>(枚举类的类名.class)
SotedMap
SortedMap是接口,它的实现类是TreeMap。 SortedMap保证遍历时以Key的顺序来进行排序。例如,放入的Key是"apple"、"pear"、"orange",遍历的顺序一定是"apple"、"orange"、"pear",因为String默认按字母排序: import java.util.*; public class Main { public static void main(String[] args) { Map<String, Integer> map = new TreeMap<>(); map.put("orange", 1); map.put("apple", 2); map.put("pear", 3); for (String key : map.keySet()) { System.out.println(key); } // apple, orange, pear } } 运行结果: apple orange pear
TreeMap
TreeMap正常运行的保证
使用TreeMap时,放入的Key必须实现Comparable接口。String、Integer这些类已经实现了Comparable接口,因此可以直接作为Key使用。作为Value的对象则没有任何要求。 如果作为Key的class没有实现Comparable接口,那么,必须在创建TreeMap时同时指定一个自定义排序算法: import java.util.*; public class Main { public static void main(String[] args) { Map<Person, Integer> map = new TreeMap<>(new Comparator<Person>() { public int compare(Person p1, Person p2) { return p1.name.compareTo(p2.name); } }); map.put(new Person("Tom"), 1); map.put(new Person("Bob"), 2); map.put(new Person("Lily"), 3); for (Person key : map.keySet()) { System.out.println(key); } // {Person: Bob}, {Person: Lily}, {Person: Tom} System.out.println(map.get(new Person("Bob"))); // 2 } } class Person { public String name; Person(String name) { this.name = name; } public String toString() { return "{Person: " + name + "}"; } } 运行结果: {Person: Bob} {Person: Lily} {Person: Tom} 2 注意到Comparator接口要求实现一个比较方法,它负责比较传入的两个元素a和b,如果a<b,则返回负数,通常是-1,如果a==b,则返回0,如果a>b,则返回正数,通常是1。TreeMap内部根据比较结果对Key进行排序。 从上述代码执行结果可知,打印的Key确实是按照Comparator定义的顺序排序的。如果要根据Key查找Value,我们可以传入一个new Person("Bob")作为Key,它会返回对应的Integer值2。 另外,注意到Person类并未覆写equals()和hashCode(),因为TreeMap不使用equals()和hashCode()。
作为Key的对象必须实现Comparable接口,进而可以比较对象之间的大小关系
Comparator接口要求实现一个比较方法,它负责比较传入的两个元素a和b,TreeMap内部根据比较结果对Key进行排序。
比较函数出错的例子: 定义了Student类,并用分数score进行排序,高分在前: import java.util.*; public class Main { public static void main(String[] args) { Map<Student, Integer> map = new TreeMap<>(new Comparator<Student>() { public int compare(Student p1, Student p2) { return p1.score > p2.score ? -1 : 1; } }); map.put(new Student("Tom", 77), 1); map.put(new Student("Bob", 66), 2); map.put(new Student("Lily", 99), 3); for (Student key : map.keySet()) { System.out.println(key); } System.out.println(map.get(new Student("Bob", 66))); // null? } } class Student { public String name; public int score; Student(String name, int score) { this.name = name; this.score = score; } public String toString() { return String.format("{%s: score=%d}", name, score); } } 运行结果: {Lily: score=99} {Tom: score=77} {Bob: score=66} null 在for循环中,我们确实得到了正确的顺序。但是,且慢!根据相同的Key:new Student("Bob", 66)进行查找时,结果为null! 这是怎么肥四?难道TreeMap有问题?遇到TreeMap工作不正常时,我们首先回顾Java编程基本规则:出现问题,不要怀疑Java标准库,要从自身代码找原因。 在这个例子中,TreeMap出现问题,原因其实出在这个Comparator上: public int compare(Student p1, Student p2) { return p1.score > p2.score ? -1 : 1; } 在p1.score和p2.score不相等的时候,它的返回值是正确的,但是,在p1.score和p2.score相等的时候,它并没有返回0!这就是为什么TreeMap工作不正常的原因:TreeMap在比较两个Key是否相等时,依赖Key的compareTo()方法或者Comparator.compare()方法。在两个Key相等时,必须返回0。因此,修改代码如下: public int compare(Student p1, Student p2) { if (p1.score == p2.score) { return 0; } return p1.score > p2.score ? -1 : 1; } 或者直接借助Integer.compare(int, int)也可以返回正确的比较结果。 但是上述作为compare比较的成分就设计的合理了? import java.util.Comparator; import java.util.Map; import java.util.TreeMap; public class HelloWorld { public static void main(String[] args) { Map<Student, Integer> map = new TreeMap<>(new Comparator<Student>() { public int compare(Student p1, Student p2) { return Integer.compare(p1.score, p2.score); } }); map.put(new Student("Tom", 77), 1); map.put(new Student("Bob", 66), 2); map.put(new Student("Lily", 99), 3); map.put(new Student("Zhang", 66), 9); for (Student key : map.keySet()) { System.out.println(key); } System.out.println(map.get(new Student("Bob", 66))); // 9 or 6? } } class Student { public String name; public int score; Student(String name, int score) { this.name = name; this.score = score; } public String toString() { return String.format("{%s: score=%d}", name, score); } } 运行输出: {Bob: score=66} {Tom: score=77} {Lily: score=99} 9
如果a<b,则返回负数,通常是-1
如果a==b,则返回0
如果a>b,则返回正数,通常是1。
Properties
Java集合库提供了一个Properties来表示一组“配置”。由于历史遗留原因,Properties内部本质上是一个Hashtable,但我们只需要用到Properties自身关于读写配置的接口。
写入配置文件
import java.io.FileOutputStream; import java.io.IOException; import java.util.Properties; public class HelloWorld { public static void main(String[] args) throws IOException { Properties props = new Properties(); props.setProperty("blog", "望江人工智库"); props.setProperty("url", "https://yuanxiaosc.github.io/"); props.setProperty("language", "Java"); props.store(new FileOutputStream("setting.properties"), "这是写入的properties注释"); } }
读取配置文件
Java默认配置文件以.properties为扩展名,每行以key=value表示,以#课开头的是注释。以下是一个典型的配置文件: # setting.properties blog=望江人工智库 url=https://yuanxiaosc.github.io/ language=Java 可以从文件系统读取这个.properties文件。 用Properties读取配置文件,一共有三步: 创建Properties实例; 调用load()读取文件; 调用getProperty()获取配置。 调用getProperty()获取配置时,如果key不存在,将返回null。我们还可以提供一个默认值,这样,当key不存在的时候,就返回默认值。如果有多个.properties文件,可以反复调用load()读取,后读取的key-value会覆盖已读取的key-value。 import java.io.IOException; import java.util.Properties; public class HelloWorld { public static void main(String[] args) throws IOException { String f = "setting.properties"; Properties props = new Properties(); props.load(new java.io.FileInputStream(f)); String blog = props.getProperty("blog"); String url = props.getProperty("url"); String language = props.getProperty("language"); System.out.println(blog); System.out.println(url); System.out.println(language); } }
读取字节流
因为load(InputStream)方法接收一个InputStream实例,表示一个字节流,它不一定是文件流,也可以是从内存读取一个字节流: import java.io.ByteArrayInputStream; import java.io.IOException; import java.util.Properties; public class HelloWorld { public static void main(String[] args) throws IOException { String settings = "# test" + "\n" + "course=Java" + "\n" + "last_open_date=2020-03-29T12:35:01"; ByteArrayInputStream input = new ByteArrayInputStream(settings.getBytes("UTF-8")); Properties props = new Properties(); props.load(input); System.out.println("course: " + props.getProperty("course")); System.out.println("last_open_date: " + props.getProperty("last_open_date")); System.out.println("last_open_file: " + props.getProperty("last_open_file")); System.out.println("auto_save: " + props.getProperty("auto_save", "60")); } }
编码问题
早期版本的Java规定.properties文件编码是ASCII编码(ISO8859-1),如果涉及到中文就必须用name=\u4e2d\u6587来表示,非常别扭。从JDK9开始,Java的.properties文件可以使用UTF-8编码了。 不过,需要注意的是,由于load(InputStream)默认总是以ASCII编码读取字节流,所以会导致读到乱码。我们需要用另一个重载方法load(Reader)读取: Properties props = new Properties(); props.load(new FileReader("settings.properties", StandardCharsets.UTF_8)); 就可以正常读取中文。InputStream和Reader的区别是一个是字节流,一个是字符流。字符流在内存中已经以char类型表示了,不涉及编码问题。
Set
Set用于存储不重复的元素集合。
创建:Set<String> set = new HashSet<>()
HashSet是无序的,因为它实现了Set接口,并没有实现SortedSet接口。
将元素添加进Set<E>:boolean add(E e)
将元素从Set<E>删除:boolean remove(Object e)
判断是否包含元素:boolean contains(Object e)
Set接口方法
import java.util.*; public class Main { public static void main(String[] args) { Set<String> set = new HashSet<>(); System.out.println(set.add("abc")); // true System.out.println(set.add("xyz")); // true System.out.println(set.add("xyz")); // false,添加失败,因为元素已存在 System.out.println(set.contains("xyz")); // true,元素存在 System.out.println(set.contains("XYZ")); // false,元素不存在 System.out.println(set.remove("hello")); // false,删除失败,因为元素不存在 System.out.println(set.size()); // 2,一共两个元素 } } 运行结果: true true false true false false 2
Set特点:Set实际上相当于只存储key、不存储value的Map。我们经常用Set用于去除重复元素。
因为放入Set的元素和Map的key类似,都要正确实现equals()和hashCode()方法,否则该元素无法正确地放入Set。 最常用的Set实现类是HashSet,实际上,HashSet仅仅是对HashMap的一个简单封装,它的核心代码如下: public class HashSet<E> implements Set<E> { // 持有一个HashMap: private HashMap<E, Object> map = new HashMap<>(); // 放入HashMap的value: private static final Object PRESENT = new Object(); public boolean add(E e) { return map.put(e, PRESENT) == null; } public boolean contains(Object o) { return map.containsKey(o); } public boolean remove(Object o) { return map.remove(o) == PRESENT; } }
Set正常运行的保证:因为放入Set的元素和Map的key类似,都要正确实现equals()和hashCode()方法,否则该元素无法正确地放入Set。
SotedSet
Set接口并不保证有序,而SortedSet接口则保证元素是有序的: HashSet是无序的,因为它实现了Set接口,并没有实现SortedSet接口; TreeSet是有序的,因为它实现了SortedSet接口。
TreeSet
import java.util.*; public class Main { public static void main(String[] args) { Set<String> set = new TreeSet<>(); set.add("apple"); set.add("banana"); set.add("pear"); set.add("orange"); for (String s : set) { System.out.println(s); } } } 运行结果: apple banana orange pear 使用TreeSet和使用TreeMap的要求一样,添加的元素必须正确实现Comparable接口,如果没有实现Comparable接口,那么创建TreeSet时必须传入一个Comparator对象。
TreeSet正常运行的保证:使用TreeSet和使用TreeMap的要求一样,添加的元素必须正确实现Comparable接口,如果没有实现Comparable接口,那么创建TreeSet时必须传入一个Comparator对象。
在聊天软件中,发送方发送消息时,遇到网络超时后就会自动重发,因此,接收方可能会收到重复的消息,在显示给用户看的时候,需要首先去重。请下面使用Set去除重复的消息: import java.util.*; public class HelloWorld { public static void main(String[] args) { List<Message> received = List.of( new Message(1, "Hello!"), new Message(2, "发工资了吗?"), new Message(2, "发工资了吗?"), new Message(3, "去哪吃饭?"), new Message(3, "去哪吃饭?"), new Message(4, "Bye") ); List<Message> displayMessages = process(received); for (Message message : displayMessages) { System.out.println(message.text); } } static List<Message> process(List<Message> received) { Set<Message> set = new TreeSet<>(new Comparator<Message>() { @Override public int compare(Message o1, Message o2) { return Integer.compare(o1.sequence, o2.sequence); } }); set.addAll(received); List<Message> list = new ArrayList<>(); list.addAll(set); return list; } } class Message { public final int sequence; public final String text; public Message(int sequence, String text) { this.sequence = sequence; this.text = text; } } 运行结果: Hello! 发工资了吗? 去哪吃饭? Bye
Queue
队列(Queue)是一种经常使用的集合。Queue实际上是实现了一个先进先出(FIFO:First In First Out)的有序表。它和List的区别在于,List可以在任意位置添加和删除元素,而Queue只有两个操作: 把元素添加到队列末尾; 从队列头部取出元素。
创建:Queue<String> queue = new LinkedList<>()
我们还可以发现,LinkedList即实现了List接口,又实现了Queue接口,但是,在使用的时候,如果我们把它当作List,就获取List的引用,如果我们把它当作Queue,就获取Queue的引用: // 这是一个List: List<String> list = new LinkedList<>(); // 这是一个Queue: Queue<String> queue = new LinkedList<>(); 始终按照面向抽象编程的原则编写代码,可以大大提高代码的质量。
int size():获取队列长度
boolean add(E) / boolean offer(E):添加元素到队尾
E remove() / E poll():获取队首元素并从队列中删除
E element() / E peek():获取队首元素但并不从队列中删除
Queue接口操作
对于具体的实现类,有的Queue有最大队列长度限制,有的Queue没有。 注意到添加、删除和获取队列元素总是有两个方法,这是因为在添加或获取元素失败时,这两个方法的行为是不同的。我们用一个表格总结如下: throw Exception 返回false或null 添加元素到队尾 add(E e) boolean offer(E e) 取队首元素并删除 E remove() E poll() 取队首元素但不删除 E element() E peek() 举例,假设我们有一个队列,对它做一个添加操作,如果调用add()方法,当添加失败时(可能超过了队列的容量),它会抛出异常: Queue<String> q = ... try { q.add("Apple"); System.out.println("添加成功"); } catch(IllegalStateException e) { System.out.println("添加失败"); } 如果我们调用offer()方法来添加元素,当添加失败时,它不会抛异常,而是返回false: Queue<String> q = ... if (q.offer("Apple")) { System.out.println("添加成功"); } else { System.out.println("添加失败"); } 当我们需要从Queue中取出队首元素时,如果当前Queue是一个空队列,调用remove()方法,它会抛出异常: Queue<String> q = ... try { String s = q.remove(); System.out.println("获取成功"); } catch(IllegalStateException e) { System.out.println("获取失败"); } 如果我们调用poll()方法来取出队首元素,当获取失败时,它不会抛异常,而是返回null: Queue<String> q = ... String s = q.poll(); if (s != null) { System.out.println("获取成功"); } else { System.out.println("获取失败"); } 因此,两套方法可以根据需要来选择使用。 注意:不要把null添加到队列中,否则poll()方法返回null时,很难确定是取到了null元素还是队列为空。 接下来我们以poll()和peek()为例来说说“获取并删除”与“获取但不删除”的区别。对于Queue来说,每次调用poll(),都会获取队首元素,并且获取到的元素已经从队列中被删除了: import java.util.LinkedList; import java.util.Queue; public class Main { public static void main(String[] args) { Queue<String> q = new LinkedList<>(); // 添加3个元素到队列: q.offer("apple"); q.offer("pear"); q.offer("banana"); // 从队列取出元素: System.out.println(q.poll()); // apple System.out.println(q.poll()); // pear System.out.println(q.poll()); // banana System.out.println(q.poll()); // null,因为队列是空的 } } 运行结果: apple pear banana null 如果用peek(),因为获取队首元素时,并不会从队列中删除这个元素,所以可以反复获取: import java.util.LinkedList; import java.util.Queue; public class Main { public static void main(String[] args) { Queue<String> q = new LinkedList<>(); // 添加3个元素到队列: q.offer("apple"); q.offer("pear"); q.offer("banana"); // 队首永远都是apple,因为peek()不会删除它: System.out.println(q.peek()); // apple System.out.println(q.peek()); // apple System.out.println(q.peek()); // apple } } 运行结果: apple apple apple 从上面的代码中,我们还可以发现,LinkedList即实现了List接口,又实现了Queue接口,但是,在使用的时候,如果我们把它当作List,就获取List的引用,如果我们把它当作Queue,就获取Queue的引用: // 这是一个List: List<String> list = new LinkedList<>(); // 这是一个Queue: Queue<String> queue = new LinkedList<>(); 始终按照面向抽象编程的原则编写代码,可以大大提高代码的质量。
PriorityQueue
PriorityQueue实现了一个优先队列:从队首获取元素时,总是获取优先级最高的元素。 PriorityQueue默认按元素比较的顺序排序(必须实现Comparable接口),也可以通过Comparator自定义排序算法(元素就不必实现Comparable接口)。
PriorityQueue正常运行的保证:与其它Soted接口一样,需要能比较对象之间的大小,可以通过实现Comparable接口来实现。
放入PriorityQueue的元素,必须实现Comparable接口,PriorityQueue会根据元素的排序顺序决定出队的优先级。 如果我们要放入的元素并没有实现Comparable接口怎么办?PriorityQueue允许我们提供一个Comparator对象来判断两个元素的顺序。我们以银行排队业务为例,实现一个PriorityQueue: import java.util.Comparator; import java.util.PriorityQueue; import java.util.Queue; public class HelloWorld { public static void main(String[] args) { Queue<User> q = new PriorityQueue<>(new UserComparator()); // 添加3个元素到队列: q.offer(new User("Bob", "A1")); q.offer(new User("Sam", "A10")); q.offer(new User("Alice", "A2")); q.offer(new User("Boss", "V1")); System.out.println(q.poll()); // Boss/V1 System.out.println(q.poll()); // Bob/A1 System.out.println(q.poll()); // Alice/A2 System.out.println(q.poll()); // Sam/A10 System.out.println(q.poll()); // null,因为队列为空 } } class UserComparator implements Comparator<User> { public int compare(User u1, User u2) { if (u1.number.charAt(0) == u2.number.charAt(0)) { // 如果两人的号都是A开头或者都是V开头,比较号的大小: return Integer.compare(Integer.parseInt(u1.number.substring(1, u1.number.length())), Integer.parseInt(u2.number.substring(1, u2.number.length()))); } if (u1.number.charAt(0) == 'V') { // u1的号码是V开头,优先级高: return -1; } else { return 1; } } } class User { public final String name; public final String number; public User(String name, String number) { this.name = name; this.number = number; } public String toString() { return name + "/" + number; } } 运行结果: Boss/V1 Bob/A1 Alice/A2 Sam/A10 null 实现PriorityQueue的关键在于提供的UserComparator对象,它负责比较两个元素的大小(较小的在前)。UserComparator总是把V开头的号码优先返回,只有在开头相同的时候,才比较号码大小。
Deque
Java集合提供了接口Deque来实现一个双端队列,它的功能是: 既可以添加到队尾,也可以添加到队首; 既可以从队首获取,又可以从队尾获取。
创建:Deque<String> d2 = new LinkedList<>()
我们发现LinkedList真是一个全能选手,它即是List,又是Queue,还是Deque。但是我们在使用的时候,总是用特定的接口来引用它,这是因为持有接口说明代码的抽象层次更高,而且接口本身定义的方法代表了特定的用途。 // 不推荐的写法: LinkedList<String> d1 = new LinkedList<>(); d1.offerLast("z"); // 推荐的写法: Deque<String> d2 = new LinkedList<>(); d2.offerLast("z"); 可见面向抽象编程的一个原则就是:尽量持有接口,而不是具体的实现类。
将元素添加到队尾或队首:addLast()/offerLast()/addFirst()/offerFirst()
从队首/队尾获取元素并删除:removeFirst()/pollFirst()/removeLast()/pollLast()
从队首/队尾获取元素但不删除:getFirst()/peekFirst()/getLast()/peekLast()
Deque接口操作
Queue是队列,只能队尾进,对首出。 Java集合提供了接口Deque来实现一个双端队列,它的功能是: 既可以添加到队尾,也可以添加到队首; 既可以从队首获取,又可以从队尾获取。 我们来比较一下Queue和Deque出队和入队的方法: Queue Deque 添加元素到队尾 add(E e) / offer(E e) addLast(E e) / offerLast(E e) 取队首元素并删除 E remove() / E poll() E removeFirst() / E pollFirst() 取队首元素但不删除 E element() / E peek() E getFirst() / E peekFirst() 添加元素到队首 无 addFirst(E e) / offerFirst(E e) 取队尾元素并删除 无 E removeLast() / E pollLast() 取队尾元素但不删除 无 E getLast() / E peekLast() 对于添加元素到队尾的操作,Queue提供了add()/offer()方法,而Deque提供了addLast()/offerLast()方法。添加元素到对首、取队尾元素的操作在Queue中不存在,在Deque中由addFirst()/removeLast()等方法提供。 注意到Deque接口实际上扩展自Queue: public interface Deque<E> extends Queue<E> { ... } 因此,Queue提供的add()/offer()方法在Deque中也可以使用,但是,使用Deque,最好不要调用offer(),而是调用offerLast(): import java.util.Deque; import java.util.LinkedList; public class Main { public static void main(String[] args) { Deque<String> deque = new LinkedList<>(); deque.offerLast("A"); // A deque.offerLast("B"); // B -> A deque.offerFirst("C"); // B -> A -> C System.out.println(deque.pollFirst()); // C, 剩下B -> A System.out.println(deque.pollLast()); // B System.out.println(deque.pollFirst()); // A System.out.println(deque.pollFirst()); // null } } 运行输出: C B A null
Stack
栈(Stack)是一种后进先出(LIFO:Last In First Out)的数据结构。
创建:Stack<String> stack = new Stack<>()
把元素压栈:push(E)
把栈顶的元素“弹出”:pop(E)
取栈顶元素但不弹出:peek(E)
Stack接口操作
为什么Java的集合类没有单独的Stack接口呢?因为有个遗留类名字就叫Stack,出于兼容性考虑,所以没办法创建Stack接口,只能用Deque接口来“模拟”一个Stack了。 当我们把Deque作为Stack使用时,注意只调用push()/pop()/peek()方法,不要调用addFirst()/removeFirst()/peekFirst()方法,这样代码更加清晰。
例子:进制转换
利用Stack把一个给定的整数转换为十六进制: import java.util.Stack; public class HelloWorld { public static void main(String[] args) { String hex = toHex(12500); if (hex.equalsIgnoreCase("30D4")) { System.out.println("测试通过"); } else { System.out.println("测试失败"); } } static String toHex(int n) { Stack<String> stack = new Stack<>(); int remainder; while (n>0){ String alpha = "0123456789ABCDEF"; remainder = n % 16; n = n / 16; stack.push(String.valueOf(alpha.charAt(remainder))); } String output = ""; while (!stack.empty()){ output+=stack.pop(); } return output; } }
Iteratior
Java的集合类都可以使用for each循环,List、Set和Queue会迭代每个元素,Map会迭代每个key。这是因为Java提供了标准的迭代器模型,即集合类实现java.util.Iterable接口,返回java.util.Iterator实例。以List为例: List<String> list = List.of("Apple", "Orange", "Pear"); for (String s : list) { System.out.println(s); } 实际上,Java编译器并不知道如何遍历List。上述代码能够编译通过,只是因为编译器把for each循环通过Iterator改写为了普通的for循环: for (Iterator<String> it = list.iterator(); it.hasNext(); ) { String s = it.next(); System.out.println(s); } 我们把这种通过Iterator对象遍历集合的模式称为迭代器。 使用迭代器的好处在于,调用方总是以统一的方式遍历各种集合类型,而不必关系它们内部的存储结构。 例如,我们虽然知道ArrayList在内部是以数组形式存储元素,并且,它还提供了get(int)方法。虽然我们可以用for循环遍历: for (int i=0; i<list.size(); i++) { Object value = list.get(i); }
for each 循环
如果我们自己编写了一个集合类,想要使用for each循环,只需满足以下条件: 集合类实现Iterable接口,该接口要求返回一个Iterator对象; 用Iterator对象迭代集合内部数据。 这里的关键在于,集合类通过调用iterator()方法,返回一个Iterator对象,这个对象必须自己知道如何遍历该集合。 一个简单的Iterator示例如下,它总是以倒序遍历集合: // Iterator import java.util.*; public class Main { public static void main(String[] args) { ReverseList<String> rlist = new ReverseList<>(); rlist.add("Apple"); rlist.add("Orange"); rlist.add("Pear"); for (String s : rlist) { System.out.println(s); } } } class ReverseList<T> implements Iterable<T> { private List<T> list = new ArrayList<>(); public void add(T t) { list.add(t); } @Override public Iterator<T> iterator() { return new ReverseIterator(list.size()); } class ReverseIterator implements Iterator<T> { int index; ReverseIterator(int index) { this.index = index; } @Override public boolean hasNext() { return index > 0; } @Override public T next() { index--; return ReverseList.this.list.get(index); } } } 运行结果: Pear Orange Apple 虽然ReverseList和ReverseIterator的实现类稍微比较复杂,但是,注意到这是底层集合库,只需编写一次。而调用方则完全按for each循环编写代码,根本不需要知道集合内部的存储逻辑和遍历逻辑。 在编写Iterator的时候,我们通常可以用一个内部类来实现Iterator接口,这个内部类可以直接访问对应的外部类的所有字段和方法。例如,上述代码中,内部类ReverseIterator可以用ReverseList.this获得当前外部类的this引用,然后,通过这个this引用就可以访问ReverseList的所有字段和方法。
Collections
Collections是JDK提供的工具类,同样位于java.util包中。它提供了一系列静态方法,能更方便地操作各种集合。
创建集合
创建空集合 Collections提供了一系列方法来创建空集合: 创建空List:List<T> emptyList() 创建空Map:Map<K, V> emptyMap() 创建空Set:Set<T> emptySet() 要注意到返回的空集合是不可变集合,无法向其中添加或删除元素。 此外,也可以用各个集合接口提供的of(T...)方法创建空集合。例如,以下创建空List的两个方法是等价的: List<String> list1 = List.of(); List<String> list2 = Collections.emptyList(); 创建单元素集合 Collections提供了一系列方法来创建一个单元素集合: 创建一个元素的List:List<T> singletonList(T o) 创建一个元素的Map:Map<K, V> singletonMap(K key, V value) 创建一个元素的Set:Set<T> singleton(T o) 要注意到返回的单元素集合也是不可变集合,无法向其中添加或删除元素。 此外,也可以用各个集合接口提供的of(T...)方法创建单元素集合。例如,以下创建单元素List的两个方法是等价的: List<String> list1 = List.of("apple"); List<String> list2 = Collections.singletonList("apple"); 实际上,使用List.of(T...)更方便,因为它既可以创建空集合,也可以创建单元素集合,还可以创建任意个元素的集合: List<String> list1 = List.of(); // empty list List<String> list2 = List.of("apple"); // 1 element List<String> list3 = List.of("apple", "pear"); // 2 elements List<String> list4 = List.of("apple", "pear", "orange"); // 3 elements
排序:Collections.sort(list)
Collections可以对List进行排序。因为排序会直接修改List元素的位置,因此必须传入可变List: import java.util.*; public class Main { public static void main(String[] args) { List<String> list = new ArrayList<>(); list.add("apple"); list.add("pear"); list.add("orange"); // 排序前: System.out.println(list); Collections.sort(list); // 排序后: System.out.println(list); } } 运行结果: [apple, pear, orange] [apple, orange, pear]
洗牌:Collections.shuffle(list)
Collections提供了洗牌算法,即传入一个有序的List,可以随机打乱List内部元素的顺序,效果相当于让计算机洗牌: import java.util.*; public class Main { public static void main(String[] args) { List<Integer> list = new ArrayList<>(); for (int i=0; i<10; i++) { list.add(i); } // 洗牌前: System.out.println(list); Collections.shuffle(list); // 洗牌后: System.out.println(list); } } 运行结果: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] [2, 3, 5, 0, 8, 1, 6, 4, 9, 7]
可变集合->不可变集合:List<String> immutable = Collections.unmodifiableList(mutable)
Collections还提供了一组方法把可变集合封装成不可变集合: 封装成不可变List:List<T> unmodifiableList(List<? extends T> list) 封装成不可变Set:Set<T> unmodifiableSet(Set<? extends T> set) 封装成不可变Map:Map<K, V> unmodifiableMap(Map<? extends K, ? extends V> m) 这种封装实际上是通过创建一个代理对象,拦截掉所有修改方法实现的。我们来看看效果: import java.util.*; public class Main { public static void main(String[] args) { List<String> mutable = new ArrayList<>(); mutable.add("apple"); mutable.add("pear"); // 变为不可变集合: List<String> immutable = Collections.unmodifiableList(mutable); immutable.add("orange"); // UnsupportedOperationException! } } Run Exception in thread "main" java.lang.UnsupportedOperationException at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1060) at Main.main(Main.java:9) 然而,继续对原始的可变List进行增删是可以的,并且,会直接影响到封装后的“不可变”List: import java.util.*; public class Main { public static void main(String[] args) { List<String> mutable = new ArrayList<>(); mutable.add("apple"); mutable.add("pear"); // 变为不可变集合: List<String> immutable = Collections.unmodifiableList(mutable); mutable.add("orange"); System.out.println(immutable); } } Run [apple, pear, orange] 因此,如果我们希望把一个可变List封装成不可变List,那么,返回不可变List后,最好立刻扔掉可变List的引用,这样可以保证后续操作不会意外改变原始对象,从而造成“不可变”List变化了: import java.util.*; public class Main { public static void main(String[] args) { List<String> mutable = new ArrayList<>(); mutable.add("apple"); mutable.add("pear"); // 变为不可变集合: List<String> immutable = Collections.unmodifiableList(mutable); // 立刻扔掉mutable的引用: mutable = null; System.out.println(immutable); } } 运行结果: [apple, pear]
泛型
Java泛型的局限
Java的泛型是由编译器在编译时实行的,编译器内部永远把所有类型T视为Object处理,但是,在需要转型的时候,编译器会根据T的类型自动为我们实行安全地强制转型。
反射
这种通过Class实例获取class信息的方法称为反射(Reflection)。Class类型是一个名叫Class的class,每加载一种类(或者接口),JVM就为其创建一个Class类型的实例,并关联起来。
Class类
除了int等基本类型外,Java的其他类型全部都是class(包括interface)。例如: String Object Runnable Exception ... 仔细思考,我们可以得出结论:class(包括interface)的本质是数据类型(Type)。无继承关系的数据类型无法赋值: Number n = new Double(123.456); // OK String s = new Double(123.456); // compile error! 而class是由JVM在执行过程中动态加载的。JVM在第一次读取到一种class类型时,将其加载进内存。 每加载一种class,JVM就为其创建一个Class类型的实例,并关联起来。注意:这里的Class类型是一个名叫Class的class。它长这样: public final class Class { private Class() {} } 以String类为例,当JVM加载String类时,它首先读取String.class文件到内存,然后,为String类创建一个Class实例并关联起来: Class cls = new Class(String); 这个Class实例是JVM内部创建的,如果我们查看JDK源码,可以发现Class类的构造方法是private,只有JVM能创建Class实例,我们自己的Java程序是无法创建Class实例的。 所以,JVM持有的每个Class实例都指向一个数据类型(class或interface)。 一个Class实例包含了该class的所有完整信息: ┌───────────────────────────┐ │ Class Instance │──────> String ├───────────────────────────┤ │name = "java.lang.String" │ ├───────────────────────────┤ │package = "java.lang" │ ├───────────────────────────┤ │super = "java.lang.Object" │ ├───────────────────────────┤ │interface = CharSequence...│ ├───────────────────────────┤ │field = value[],hash,... │ ├───────────────────────────┤ │method = indexOf()... │ └───────────────────────────┘ 由于JVM为每个加载的class创建了对应的Class实例,并在实例中保存了该class的所有信息,包括类名、包名、父类、实现的接口、所有方法、字段等,因此,如果获取了某个Class实例,我们就可以通过这个Class实例获取到该实例对应的class的所有信息。
获取一个class的Class实例有三种方法
直接通过一个class的静态变量class获取:Class cls = String.class
如果有一个实例变量,可以通过该实例变量提供的getClass()方法获取:String s = "Hello";Class cls = s.getClass();
如果知道一个class的完整类名,可以通过静态方法Class.forName()获取: Class cls = Class.forName("java.lang.String");
Class 实例比较与instanceof比较
因为Class实例在JVM中是唯一的,所以,上述方法获取的Class实例是同一个实例。可以用==比较两个Class实例: Class cls1 = String.class; String s = "Hello"; Class cls2 = s.getClass(); boolean sameClass = cls1 == cls2; // true 注意一下Class实例比较和instanceof的差别: Integer n = new Integer(123); boolean b1 = n instanceof Integer; // true,因为n是Integer类型 boolean b2 = n instanceof Number; // true,因为n是Number类型的子类 boolean b3 = n.getClass() == Integer.class; // true,因为n.getClass()返回Integer.class boolean b4 = n.getClass() == Number.class; // false,因为Integer.class!=Number.class 用instanceof不但匹配指定类型,还匹配指定类型的子类。而用==判断class实例可以精确地判断数据类型,但不能作子类型比较。 通常情况下,我们应该用instanceof判断数据类型,因为面向抽象编程的时候,我们不关心具体的子类型。只有在需要精确判断一个类型是不是某个class的时候,我们才使用==判断class实例。
通过反射获取该Object的class信息
因为反射的目的是为了获得某个实例的信息。因此,当我们拿到某个Object实例时,我们可以通过反射获取该Object的class信息: void printObjectInfo(Object obj) { Class cls = obj.getClass(); } 要从Class实例获取获取的基本信息,参考下面的代码: // reflection public class Main { public static void main(String[] args) { printClassInfo("".getClass()); printClassInfo(Runnable.class); printClassInfo(java.time.Month.class); printClassInfo(String[].class); printClassInfo(int.class); } static void printClassInfo(Class cls) { System.out.println("Class name: " + cls.getName()); System.out.println("Simple name: " + cls.getSimpleName()); if (cls.getPackage() != null) { System.out.println("Package name: " + cls.getPackage().getName()); } System.out.println("is interface: " + cls.isInterface()); System.out.println("is enum: " + cls.isEnum()); System.out.println("is array: " + cls.isArray()); System.out.println("is primitive: " + cls.isPrimitive()); } } 注意到数组(例如String[])也是一种Class,而且不同于String.class,它的类名是[Ljava.lang.String。此外,JVM为每一种基本类型如int也创建了Class,通过int.class访问。
通过Class实例来创建对应类型的实例
果获取到了一个Class实例,我们就可以通过该Class实例来创建对应类型的实例: // 获取String的Class实例: Class cls = String.class; // 创建一个String实例: String s = (String) cls.newInstance(); 上述代码相当于new String()。通过Class.newInstance()可以创建类实例,它的局限是:只能调用public的无参数构造方法。带参数的构造方法,或者非public的构造方法都无法通过Class.newInstance()被调用。
VM动态加载class的特性:为了在运行期根据条件加载不同的实现类。
动态加载 JVM在执行Java程序的时候,并不是一次性把所有用到的class全部加载到内存,而是第一次需要用到class时才加载。例如: // Main.java public class Main { public static void main(String[] args) { if (args.length > 0) { create(args[0]); } } static void create(String name) { Person p = new Person(name); } } 当执行Main.java时,由于用到了Main,因此,JVM首先会把Main.class加载到内存。然而,并不会加载Person.class,除非程序执行到create()方法,JVM发现需要加载Person类时,才会首次加载Person.class。如果没有执行create()方法,那么Person.class根本就不会被加载。 这就是JVM动态加载class的特性。 动态加载class的特性对于Java程序非常重要。利用JVM动态加载class的特性,我们才能在运行期根据条件加载不同的实现类。例如,Commons Logging总是优先使用Log4j,只有当Log4j不存在时,才使用JDK的logging。利用JVM动态加载特性,大致的实现代码如下: // Commons Logging优先使用Log4j: LogFactory factory = null; if (isClassPresent("org.apache.logging.log4j.Logger")) { factory = createLog4j(); } else { factory = createJdkLog(); } boolean isClassPresent(String name) { try { Class.forName(name); return true; } catch (Exception e) { return false; } } 这就是为什么我们只需要把Log4j的jar包放到classpath中,Commons Logging就会自动使用Log4j的原因。
方法重写
@Override
equals()
Java中的equals()方法
设计模式
设计模式(Design Pattern)是前辈们对代码开发经验的总结,是解决特定问题的一系列套路。它不是语法规定,而是一套用来提高代码可复用性、可维护性、可读性、稳健性以及安全性的解决方案。
Java设计模式
解读Java集合框架图