Java程序基础
一,安装开发环境
安装JDK
- 从ORACLE官网下载链接
找到下载界面window
- 下载x64 Installer
设置环境变量
噗噗噗这里没时间了我先去写作业了
快捷键
二,Hello, world!
1.public class Hello
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
Hello是类的名字,按照习惯,首字母
H
要大写。
class用来定义一个类
public表示这个类是公开的
**而花括号
{}
中间则是类的定义**
public
、
class
都是Java的关键字,必须小写,
Hello
是类的名字,按照习惯,首字母
H
要大写
快捷键作用快捷键作用Ctrl+1一阶标题Ctrl+B字体加粗Ctrl+2二阶标题Ctrl+I字体倾斜Ctrl+3三阶标题Ctrl+U下划线Ctrl+4四阶标题Ctrl+Home返回Typora顶部Ctrl+5五阶标题Ctrl+End返回Typora底部Ctrl+6六阶标题Ctrl+T创建表格Ctrl+L选中某句话Ctrl+K创建超链接Ctrl+D选中某个单词Ctrl+F搜索Ctrl+E选中相同格式的文字Ctrl+H搜索并替换Alt+Shift+5删除线Ctrl+Shift+I插入图片
2.定义一个名为main的方法
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
在类的定义中,我们定义了一个名为main的方法:
方法是可执行的代码块,一个方法除了方法名main,还有用()括起来的方法参数,这里的main方法有一个参数,
参数类型是 String[],
参数名是 args,
public、static 用来修饰方法,
这里表示它是一个公开的静态方法,void是方法的返回类型,而花括号{}中间的就是方法的代码。
3.输出Hello, world!
方法的代码每一行用 ; 结束
这里只有一行代码,就是:
System.out.println("Hello, world!");
它用来打印一个字符串到屏幕上。
**Java规定,某个类定义的
public static void main(String[] args)
是Java程序的固定入口方法,因此,Java程序总是从
main
方法开始执行。**
注意Java源码的缩进不是必须的,但是用缩进后,格式好看,很容易看出代码块的开始和结束,缩进一般是4个空格或者一个tab。
4.保存
最后,把代码保存为文件时,
- 文件名必须是Hello.java
- 文件名也要注意大小写,因为要和我们定义的类名Hello完全保持一致
三,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
1.类名要求
- 类名必须以英文字母开头,后接字母,数字和下划线的组合
- 习惯以大写字母开头
要注意遵守命名习惯,好的类命名:
- Hello
- NoteBook
- VRPlayer
不好的类命名:
- hello
- Good123
- Note_Book
- _World
2.定义一个名为main的方法
public class Hello {
public static void main(String[] args) { // 方法名是main
// 方法代码...
} // 方法定义结束
}
注意小方法:
第一行:
注意到public是访问修饰符,表示该class是公开的。
不写public,也能正确编译,但是这个类将无法从命令行执行。
在class内部,可以定义若干方法(method):
第二行:
- 方法名是main,返回值是void
- public除了可以修饰class外,也可以修饰方法。而关键字static是另一个修饰符,它表示静态方法
3.方法名
方法名也有命名规则,命名和 class 一样,但是首字母小写:
好的方法命名:
- main
- goodMorning
- playVR
不好的方法命名:
- Main
- good123
- good_morning
- _playVR
4.执行代码
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!"); // 语句
}
}
在方法内部,语句才是真正的执行代码。
Java的每一行语句必须以分号结束
5.注释
共三种
one,单行注释
以双斜线开头,直到这一行的结尾结束:
// 这是注释...
two,多行注释
以**/星号开头,以/**结束,可以有多行:
/*
这是注释
blablabla...
这也是注释
*/
three,特殊的多行注释
以**/开头,以/**结束,如果有多行,每行通常以星号开头:
/**
* 可以用来自动创建文档的注释
*
*
*/
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
这种特殊的多行注释需要写在类和方法的定义处,可以用于自动创建文档
四,变量
1.给变量赋值
Java中,变量必须先定义后使用
在定义变量的时候,可以给它一个初始值。
例如:
int x = 1;
上述语句定义了一个整型int类型的变量
名称为x,初始值为1。
不写初始值,就相当于给它指定了默认值
默认值总是0。
eg:
定义变量,然后打印变量值:
public class Main {
public static void main(String[] args) {
int x = 100; // 定义int类型变量x,并赋予初始值100
System.out.println(x); // 打印该变量的值
}
}
结果:
100
2.重新赋值
变量的一个重要特点是可以重新赋值。
eg:
对变量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
3.!!!综上总结!!!!:
注意
第一次定义变量x的时候,需要指定变量类型int,因此使用语句int x = 100;
而第二次重新赋值的时候,变量x已经存在了,不能再重复定义,因此不能指定变量类型int
必须使用语句x = 200
变量不但可以重新赋值,还可以赋值给其他变量。
eg:
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 = 100
n = 200
x = 200
x = 300
n = 200
代码流程分析
五,基本数据类型
前言:计算机内存的基本结构
- 整数类型:byte,short,int,long
- 浮点数类型:float,double
- 字符类型:char
- 布尔类型:boolean
计算机内存的最小存储单元是字节(byte)
一个字节就是一个8位二进制数,即8个bit
它的二进制表示范围从00000000~11111111
换算成十进制是0~255
换算成十六进制是00~ff
内存单元从0开始编号,称为内存地址
每个内存单元可以看作一间房间,内存地址就是门牌号
0 1 2 3 4 5 6 ...
┌───┬───┬───┬───┬───┬───┬───┐
│ │ │ │ │ │ │ │...
└───┴───┴───┴───┴───┴───┴───┘
一个字节是1byte,1024字节是1K,1024K是1M,1024M是1G,1024G是1T。
一个拥有4T内存的计算机的字节数量就是:
4T = 4 x 1024G
= 4 x 1024 x 1024M
= 4 x 1024 x 1024 x 1024K
= 4 x 1024 x 1024 x 1024 x 1024
= 4398046511104
不同的数据类型占用的字节数不一样。我们看一下Java基本数据类型占用的字节数:
┌───┐
byte │ │
└───┘
┌───┬───┐
short │ │ │
└───┴───┘
┌───┬───┬───┬───┐
int │ │ │ │ │
└───┴───┴───┴───┘
┌───┬───┬───┬───┬───┬───┬───┬───┐
long │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
┌───┬───┬───┬───┐
float │ │ │ │ │
└───┴───┴───┴───┘
┌───┬───┬───┬───┬───┬───┬───┬───┐
double │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
┌───┬───┐
char │ │ │
└───┴───┘
byte恰好就是一个字节,而long和double需要8个字节。
1.整数类型:
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
}
}
2.浮点数类型:
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。
3.字符类型:
char
字符类型 char 表示一个字符。
Java的 char 类型除了可表示标准的 ASCII 外,还可以表示一个 Unicode 字符:
eg:
public class Main {
public static void main(String[] args) {
char a = 'A';
char zh = '中';
System.out.println(a);
System.out.println(zh);
}
}
结果:
A
中
注意char类型使用单引号 ' ,且仅有一个字符,要和双引号 " 的字符串类型区分开。
4.布尔类型:
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字节整数。
5.引用类型
除了上述基本类型的变量,剩下的都是引用类型。例如,引用类型最常用的就是String字符串:
String s = "hello";
引用类型的变量类似于C语言的指针,它内部存储一个“地址”,指向某个对象在内存的位置,后续我们介绍类的概念时会详细讨论。
6.常量
定义变量的时候,如果加上 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。
根据习惯,常量名通常全部大写。
7.var关键字
有些时候,类型的名字太长,写起来比较麻烦。例如:
StringBuilder sb = new StringBuilder();
**这个时候,如果想省略变量类型,可以使用
var
关键字:**
var sb = new StringBuilder();
**编译器会根据赋值语句自动推断出变量
sb
的类型是
StringBuilder
。对编译器来说,语句:**
var sb = new StringBuilder();
实际上会自动变成:
StringBuilder sb = new StringBuilder();
**因此,使用
var
定义变量,仅仅是少写了变量类型而已。**
8.变量的作用范围
在Java中,多行语句用{ }括起来。很多控制语句,例如条件判断和循环,都以{ }作为它们自身的范围,例如:
if (...) { // if开始
...
while (...) { // while 开始
...
if (...) { // if开始
...
} // if结束
...
} // while结束
...
} // if结束
只要正确地嵌套这些{ },编译器就能识别出语句块的开始和结束。而在语句块中定义的变量,它有一个作用域,就是从定义处开始,到语句块结束。超出了作用域引用这些变量,编译器会报错。举个例子:
{
...
int i = 0; // 变量i从这里开始定义
...
{
...
int x = 1; // 变量x从这里开始定义
...
{
...
String s = "hello"; // 变量s从这里开始定义
...
} // 变量s作用域到此结束
...
// 注意,这是一个新的变量s,它和上面的变量同名,
// 但是因为作用域不同,它们是两个不同的变量:
String s = "hi";
...
} // 变量x和s作用域到此结束
...
} // 变量i作用域到此结束
定义变量时,要遵循作用域最小化原则,尽量将变量定义在尽可能小的作用域,并且,不要重复使用变量名。
总结:
Java提供了两种变量类型:
基本类型和引用类型
基本类型包括
整型,浮点型,布尔型,字符型。
变量可重新赋值,等号是赋值语句,不是数学意义的等号。
常量在初始化后不可重新赋值,使用常量便于理解程序意图。
六,整数运算
1.四则运算
Java的整数运算遵循四则运算规则,可以使用任意嵌套的小括号。四则运算规则和初等数学一致
public class Main {
public static void main(String[] args) {
int i = (100 + 200) * (99 - 88); // 3300
int n = 7 * (5 + (i - 9)); // 23072
System.out.println(i);
System.out.println(n);
}
}
结果:
3300
23072
整数的数值表示不但是精确的,而且整数运算永远是精确的,即使是除法也是精确的,因为两个整数相除只能得到结果的整数部分:
int x = 12345 / 67; // 184
结果:
184
求余运算使用%:
int y = 12345 % 67; // 12345÷67的余数是17
17
特别注意:整数的除法对于除数为0时运行时将报错,但编译不会报错。
2.溢出
要特别注意,整数由于存在范围限制,如果计算结果超出了范围,就会产生溢出,而溢出不会出错,却会得到一个奇怪的结果:
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
要解释上述结果,我们把整数
2147483640
和
15
换成二进制做加法:
0111 1111 1111 1111 1111 1111 1111 1000
+ 0000 0000 0000 0000 0000 0000 0000 1111
-----------------------------------------
1000 0000 0000 0000 0000 0000 0000 0111
由于最高位计算结果为
1
,因此,加法结果变成了一个负数。
要解决上面的问题,可以把
int
换成
long
类型,由于
long
可表示的整型范围更大,所以结果就不会溢出:
long x = 2147483640;
long y = 15;
long sum = x + y;
System.out.println(sum); // 2147483655
结果:
2147483655
3.简写运算符
+=,
-=
*=
/=
n += 100; // 3409, 相当于 n = n + 100;
n -= 100; // 3309, 相当于 n = n - 100;
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
**注意
++
写在前面和后面计算结果是不同的,**
**
++n
表示先加1再引用n,**
**
n++
表示先引用n再加1。**
**不建议把
++
运算混入到常规运算中,容易自己把自己搞懵了。**
5.移位运算
在计算机中,整数总是以二进制的形式表示。例如,
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
还有一种无符号的右移运算,使用
>>>
,它的特点是不管符号位,右移后高位总是补
0
,因此,对一个负数进行
>>>
右移,它会变成正数,原因是最高位的
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。
6.位运算
位运算是按位进行与、或、非和异或的运算。
与运算的规则是,必须两个数同时为
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
}
}
7.运算优先级
在Java的计算表达式中,运算优先级从高到低依次是:
()
!``````~``````++``````--
*``````/``````%
+``````-
<<``````>>``````>>>
&
|
+=``````-=``````*=``````/=
8.类型自动提升与强制转型
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; // 编译错误!
}
}
在运算过程中,如果参与运算的两个数类型不一致
那么计算结果为较大类型的整型。例如,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: error: incompatible types: possible lossy conversion from int to short
short y = s + i; // 编译错误!
^
1 error
error: compilation failed
也可以将结果强制转型,即将大范围的整数转型为小范围的整数。强制转型使用
(类型)
,例如,将
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);
}
}
七,浮点数运算
1.误差
浮**点数
0.1
在计算机中就无法精确表示,因为十进制的
0.1
换算成二进制是一个无限循环小数,很显然,无论使用
float
还是
double
,都只能存储一个
0.1
的近似值。但是,
0.5
这个浮点数又可以精确地表示。**
eg:
因为浮点数常常无法精确表示,因此,浮点数运算会产生误差:
public class Main {
public static void main(String[] args) {
double x = 1.0 / 10;
double y = 1 - 9.0 / 10;
// 观察x和y是否相等:
System.out.println(x);
System.out.println(y);
}
}
结果:
0.1
0.09999999999999998
由于浮点数存在运算误差,所以比较两个浮点数是否相等常常会出现错误的结果。正确的比较方法是判断两个浮点数之差的绝对值是否小于一个很小的数:
// 比较x和y是否相等,先计算其差的绝对值:
double r = Math.abs(x - y);
// 再判断绝对值是否足够小:
if (r < 0.00001) {
// 可以认为相等
} else {
// 不相等
}
浮点数在内存的表示方法和整数比更加复杂。Java的浮点数完全遵循IEEE-754标准,这也是绝大多数计算机平台都支持的浮点数标准表示方法。
2.类型提升
如果参与运算的两个数其中一个是整型,那么整型可以自动提升到浮点型:
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
计算结果为
5.2
,原因是编译器计算
24 / 5
这个子表达式时,按两个整数进行运算,结果仍为整数
4
3.溢出
整数运算在除数为
0
时会报错,而浮点数运算在除数为
0
时,不会报错,但会返回几个特殊值:
NaN
表示Not a NumberInfinity
表示无穷大-Infinity
表示负无穷大
例如:
double d1 = 0.0 / 0; // NaN
double d2 = 1.0 / 0; // Infinity
double d3 = -1.0 / 0; // -Infinity
这三种特殊值在实际运算中很少碰到,我们只需要了解即可。
4.强制转型
可以将浮点数强制转型为整数。在转型时,浮点数的小数部分会被丢掉。如果转型后超过了整型能表示的最大范围,将返回整型的最大值。例如:
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
八,布尔运算
对于布尔类型
boolean
,永远只有
true
和
false
两个值。
1.布尔运算
布尔运算是一种关系运算,包括以下几类:
- 比较运算符:
>
,>=
,<
,<=
,==
,!=
- 与运算
&&
- 或运算
||
- 非运算
!
下面是一些示例:
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
关系运算符的优先级从高到低依次是:
!
>
,>=
,<
,<=
==
,!=
&&
||
---
2.短路运算
布尔运算的一个重要特点是短路运算。如果一个布尔运算的表达式能提前确定结果,则后续的计算不再执行,直接返回结果。
因为
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
3.三元运算符
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
之一。
九,字符
字符类型
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
十,字符串
1.字符串类型
和
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, 换行符, 中, 文
2.字符串连接
Java的编译器对字符串做了特殊照顾,可以使用
+
连接任意字符串和其他数据类型,这样极大地方便了字符串的处理。例如:
public class Main {
public static void main(String[] args) {
String s1 = "Hello";
String s2 = "world";
String s = s1 + " " + s2 + "!";
System.out.println(s);
}
}
**如果用
+
连接字符串和其他数据类型,会将其他数据类型先自动转型为字符串,再连接:**
public class Main {
public static void main(String[] args) {
int age = 25;
String s = "age is " + age;
System.out.println(s);
}
}
3.多行字符串
如果我们要表示多行字符串,使用+号连接会非常不方便:
String s = "first line \n"
+ "second line \n"
+ "end";
**从Java 13开始,字符串可以用
"""..."""
表示多行字符串(Text Blocks)了**
eg:
public class Main {
public static void main(String[] args) {
String s = """
SELECT * FROM
users
WHERE id > 100
ORDER BY name DESC
""";
System.out.println(s);
}
}
**上述多行字符串实际上是5行,在最后一个
DESC
后面还有一个
\n
。如果我们不想在字符串末尾加一个
\n
,就需要这么写:**
String s = """
SELECT * FROM
users
WHERE id > 100
ORDER BY name DESC""";
还需要注意到,多行字符串前面共同的空格会被去掉,即:
String s = """
...........SELECT * FROM
........... users
...........WHERE id > 100
...........ORDER BY name DESC
...........""";
**用
.
标注的空格都会被去掉。**
如果多行字符串的排版不规则,那么,去掉的空格就会变成这样:
String s = """
......... SELECT * FROM
......... users
.........WHERE id > 100
......... ORDER BY name DESC
......... """;
即总是以最短的行首空格为基准。
4.不可变特性
Java的字符串除了是一个引用类型外,还有个重要特点,就是字符串不可变。考察以下代码:
public class Main {
public static void main(String[] args) {
String s = "hello";
System.out.println(s); // 显示 hello
s = "world";
System.out.println(s); // 显示 world
}
}
原理
观察执行结果,难道字符串
s
变了吗?其实变的不是字符串,而是变量
s
的“指向”。
执行
String s = "hello";
时,JVM虚拟机先创建字符串
"hello"
,然后,把字符串变量
s
指向它:
s
│
▼
┌───┬───────────┬───┐
│ │ "hello" │ │
└───┴───────────┴───┘
紧接着,执行
s = "world";
时,JVM虚拟机先创建字符串
"world"
,然后,把字符串变量
s
指向它:
s ──────────────┐
│
▼
┌───┬───────────┬───┬───────────┬───┐
│ │ "hello" │ │ "world" │ │
└───┴───────────┴───┴───────────┴───┘
原来的字符串
"hello"
还在,只是我们无法通过变量
s
访问它而已。因此,字符串的不可变是指字符串内容不可变。
理解了引用类型的“指向”后,试解释下面的代码输出:
public class Main {
public static void main(String[] args) {
String s = "hello";
String t = s;
s = "world";
System.out.println(t); // t是"hello"还是"world"?
}
}
//hello
空值null
引用类型的变量可以指向一个空值
null
,它表示不存在,即该变量不指向任何对象。例如:
String s1 = null; // s1是null
String s2; // 没有赋初值值,s2也是null
String s3 = s1; // s3也是null
String s4 = ""; // s4指向空字符串,不是null
注意要区分空值
null
和空字符串
""
,空字符串是一个有效的字符串对象,它不等于
null
。
十一,数组类型
简介
如果我们有一组类型相同的变量,例如,5位同学的成绩,可以这么写:
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int n1 = 68;
int n2 = 79;
int n3 = 91;
int n4 = 85;
int n5 = 62;
}
}
**但其实没有必要定义5个
int
变量。可以使用数组来表示“一组”
int
类型。代码如下:**
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns = new int[5];
ns[0] = 68;
ns[1] = 79;
ns[2] = 91;
ns[3] = 85;
ns[4] = 62;
}
}
定义一个数组类型的变量,使用数组类型“类型[]”,例如,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
}
}
结果:
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不能超出范围
}
}
也可以在定义数组时直接指定初始化的元素,这样就不必写出数组大小,而是由编译器自动推算数组大小。例如:
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
}
}
还可以进一步简写为:
int[] ns = { 68, 79, 91, 85, 62 };
注意数组是引用类型,并且数组大小不可变。我们观察下面的代码:
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns;
ns = new int[] { 68, 79, 91, 85, 62 };
System.out.println(ns.length); // 5
ns = new int[] { 1, 2, 3 };
System.out.println(ns.length); // 3
}
}
原理
数组大小变了吗?看上去好像是变了,但其实根本没变。
对于数组
ns
来说,执行
ns = new int[] { 68, 79, 91, 85, 62 };
时,它指向一个5个元素的数组:
ns
│
▼
┌───┬───┬───┬───┬───┬───┬───┐
│ │68 │79 │91 │85 │62 │ │
└───┴───┴───┴───┴───┴───┴───┘
执行
ns = new int[] { 1, 2, 3 };
时,它指向一个新的3个元素的数组:
ns ──────────────────────┐
│
▼
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ │68 │79 │91 │85 │62 │ │ 1 │ 2 │ 3 │ │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
但是,原有的5个元素的数组并没有改变,只是无法通过变量
ns
引用到它们而已。
字符串数组
如果数组元素不是基本类型,而是一个引用类型,那么,修改数组元素会有哪些不同?
字符串是引用类型,因此我们先定义一个字符串数组:
String[] names = {
"ABC", "XYZ", "zoo"
};
对于
String[]
类型的数组变量
names
,它实际上包含3个元素,但每个元素都指向某个字符串对象:
┌─────────────────────────┐
names │ ┌─────────────────────┼───────────┐
│ │ │ │ │
▼ │ │ ▼ ▼
┌───┬───┬─┴─┬─┴─┬───┬───────┬───┬───────┬───┬───────┬───┐
│ │░░░│░░░│░░░│ │ "ABC" │ │ "XYZ" │ │ "zoo" │ │
└───┴─┬─┴───┴───┴───┴───────┴───┴───────┴───┴───────┴───┘
│ ▲
└─────────────────┘
对
names[1]
进行赋值,例如
names[1] = "cat";
,效果如下:
┌─────────────────────────────────────────────────┐
names │ ┌─────────────────────────────────┐ │
│ │ │ │ │
▼ │ │ ▼ ▼
┌───┬───┬─┴─┬─┴─┬───┬───────┬───┬───────┬───┬───────┬───┬───────┬───┐
│ │░░░│░░░│░░░│ │ "ABC" │ │ "XYZ" │ │ "zoo" │ │ "cat" │ │
└───┴─┬─┴───┴───┴───┴───────┴───┴───────┴───┴───────┴───┴───────┴───┘
│ ▲
└─────────────────┘
这里注意到原来
names[1]
指向的字符串
"XYZ"
并没有改变,仅仅是将
names[1]
的引用从指向
"XYZ"
改成了指向
"cat"
,其结果是字符串
"XYZ"
再也无法通过
names[1]
访问到了。
对“指向”有了更深入的理解后,试解释如下代码:
public class Main {
public static void main(String[] args) {
String[] names = {"ABC", "XYZ", "zoo"};
String s = names[1];
names[1] = "cat";
System.out.println(s); // s是"XYZ"还是"cat"?
}
}
答:
XYZ
十二,输入和输出
1.输出
**在前面的代码中,我们总是使用
System.out.println()
来向屏幕输出一些内容。**
**
println
是print line的缩写,表示输出并换行。**
**因此,如果输出后不想换行,可以用
print()
:**
public class Test {
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");
}
}
2.格式化输出
Java还提供了格式化输出的功能。为什么要格式化输出?因为计算机表示的数据不一定适合人来阅读:
public class Test {
public static void main(String[] args) {
double d = 12900000;
System.out.println(d); // 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); // 注意,两个%占位符必须传入两个数
}
}
3.输入
和输出相比,Java的输入就要复杂得多。
我们先看一个从控制台读取一个字符串和一个整数的例子:
import java.util.Scanner;
public class Test {
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); // 格式化输出
}
}
猪猪猪:
当通过new Scanner(System.in) 创建一个Scanner,控制台 会一直等待输入,,,,,,,直到敲 回车键结束,把所输入的内容 传给Scanner,作为扫描对象。
如果要 获取输入的内容,则只需要调用Scanner的nextLine()方法即可。
- 首先,我们通过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
这个程序编译时如果有警告,可以暂时忽略它,在后面学习IO的时候再详细解释。编译成功后,执行:
$ java Main
Input your name: Bob
Input your age: 12
Hi, Bob, you are 12
根据提示分别输入一个字符串和整数后,我们得到了格式化的输出。
总结
Java提供的输出包括:
System.out.println()
print()
printf()
其中printf()可以格式化输出;
Java提供Scanner对象来方便输入
读取对应的类型可以使用:
**scanner.nextLine() / **
**nextInt() / **
nextDouble() / ...
十三,if判断
要根据条件来决定是否执行某一段代码
1.if基本语法
if
语句的基本语法是:
if (条件) {
// 条件满足时执行
}
根据if的计算结果(true还是false)JVM决定是否执行if语句块(即花括号{}包含的所有语句)
eg:
public class Test {
public static void main(String[] args) {
int n = 70;
if (n >= 60) {
System.out.println("及格了");
System.out.println("恭喜你");
}
System.out.println("END");
}
}
当条件n >= 60计算结果为true时,if语句块被执行,将打印"及格了"
否则,if语句块将被跳过。
修改n的值可以看到执行效果。
注意到if语句包含的块可以包含多条语句:
public class Test {
public static void main(String[] args) {
int n = 70;
if (n >= 60) {
System.out.println("及格了");
System.out.println("恭喜你");
}
System.out.println("END");
}
}
当if语句块只有一行语句时,可以省略花括号{}:
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 60)
System.out.println("及格了");
System.out.println("END");
}
}
2.else
if语句还可以编写一个else { ... },
当条件判断为false时
将执行else的语句块
public class Main {
public static void main(String[] args) {
int n = 70;
if (n >= 60) {
System.out.println("及格了");
} else {
System.out.println("挂科了");
}
System.out.println("END");
}
}
修改上述代码n的值
观察if条件为true或false时
程序执行的语句块。
注意,else不是必须的。
还可以用多个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");
}
}
3.误差
使用if时,还要特别注意边界条件。例如:
public class Main {
public static void main(String[] args) {
int n = 90;
if (n > 90) {
System.out.println("优秀");
} else if (n >= 60) {
System.out.println("及格了");
} else {
System.out.println("挂科了");
}
}
}
假设我们期望90分或更高为“优秀”,上述代码输出的却是“及格”,原因是>和>=效果是不同的。
前面讲过了浮点数在计算机中常常无法精确表示,并且计算可能出现误差,因此,判断浮点数相等用==判断不靠谱:
public class Main {
public static void main(String[] args) {
double x = 1 - 9.0 / 10;
if (x == 0.1) {
System.out.println("x is 0.1");
} else {
System.out.println("x is NOT 0.1");
}
}
}
正确的方法是利用差值小于某个临界值来判断:
public class Main {
public static void main(String[] args) {
double x = 1 - 9.0 / 10;
if (Math.abs(x - 0.1) < 0.00001) {
System.out.println("x is 0.1");
} else {
System.out.println("x is NOT 0.1");
}
}
}
4.判断引用类型相等
1.判断值类型的变量是否相等
在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");
}
}
}
2.判断引用类型的变量内容是否相等
要判断引用类型的变量内容是否相等,必须使用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");
}
}
}
3.注意易错
注意:
执行语句
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");
}
}
}
要避免
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
结束执行。**
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;
}
}
}
**修改
option
的值分别为
1
、
2
、
3
,观察执行结果。**
**如果
option
的值没有匹配到任何
case
,例如
option = 99
,那么,
switch
语句不会执行任何语句。这时,可以给
switch
语句加一个
default
,当没有匹配到任何
case
时,执行
default
:**
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;
}
}
}
**如果把
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
语句中对每个
case
正确编写了
break
语句才能对应得上。**
**使用
switch
时,注意
case
语句并没有花括号
{}
,而且,
case
语句具有“穿透性”,漏写
break
将导致意想不到的结果:**
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");
}
}
}
当
option = 2
时,将依次输出
"Selected 2"
、
"Selected 3"
、
"Not selected"
,原因是从匹配到
case 2
开始,后续语句将全部执行,直到遇到
break
语句。因此,任何时候都不要忘记写
break
。
如果有几个
case
语句执行的是同一组语句块,可以这么写:
public class Main {
public static void main(String[] args) {
int option = 2;
switch (option) {
case 1:
System.out.println("Selected 1");
break;
case 2:
case 3:
System.out.println("Selected 2, 3");
break;
default:
System.out.println("Not selected");
break;
}
}
}
使用
switch
语句时,只要保证有
break
,
case
的顺序不影响程序逻辑:
switch (option) {
case 3:
...
break;
case 2:
...
break;
case 1:
...
break;
}
但是仍然建议按照自然顺序排列,便于阅读。
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;
}
}
}
编译检查
使用IDE时,可以自动检查是否漏写了
break
语句和
default
语句,方法是打开IDE的编译检查。
在Eclipse中,选择
Preferences
-
Java
-
Compiler
-
Errors/Warnings
-
Potential programming problems
,将以下检查标记为Warning:
- 'switch' is missing 'default' case
- 'switch' case fall-through
在Idea中,选择
Preferences
-
Editor
-
Inspections
-
Java
-
Control flow issues
,将以下检查标记为Warning:
- Fallthrough in 'switch' statement
- 'switch' statement without 'default' branch
当
switch
语句存在问题时,即可在IDE中获得警告提示。
switch表达式
**使用
switch
时,如果遗漏了
break
,就会造成严重的逻辑错误,而且不易在源代码中发现错误。从Java 12开始,
switch
语句升级为更简洁的表达式语法,使用类似模式匹配(Pattern Matching)的方法,保证只有一种路径会被执行,并且不需要
break
语句:**
public class Main {
public static void main(String[] args) {
String fruit = "apple";
switch (fruit) {
case "apple" -> System.out.println("Selected apple");
case "pear" -> System.out.println("Selected pear");
case "mango" -> {
System.out.println("Selected mango");
System.out.println("Good choice!");
}
default -> System.out.println("No fruit selected");
}
}
}
**注意新语法使用
->
,如果有多条语句,需要用
{}
括起来。不要写
break
语句,因为新语法只会执行匹配的语句,没有穿透效应。**
**很多时候,我们还可能用
switch
语句给某个变量赋值。例如**
int opt;
switch (fruit) {
case "apple":
opt = 1;
break;
case "pear":
case "mango":
opt = 2;
break;
default:
opt = 0;
break;
}
**使用新的
switch
语法,不但不需要
break
,还可以直接返回值。把上面的代码改写如下:**
int opt;
switch (fruit) {
case "apple":
opt = 1;
break;
case "pear":
case "mango":
opt = 2;
break;
default:
opt = 0;
break;
}
**使用新的
switch
语法,不但不需要
break
,还可以直接返回值。把上面的代码改写如下:**
public class Main {
public static void main(String[] args) {
String fruit = "apple";
int opt = switch (fruit) {
case "apple" -> 1;
case "pear", "mango" -> 2;
default -> 0;
}; // 注意赋值语句要以;结束
System.out.println("opt = " + opt);
}
}
yield
大多数时候,在
switch
表达式内部,我们会返回简单的值。
但是,如果需要复杂的语句,我们也可以写很多语句,放到
{...}
里,然后,用
yield
返回一个值作为
switch
语句的返回值:
public class Main {
public static void main(String[] args) {
String fruit = "orange";
int opt = switch (fruit) {
case "apple" -> 1;
case "pear", "mango" -> 2;
default -> {
int code = fruit.hashCode();
yield code; // switch语句返回值
}
};
System.out.println("opt = " + opt);
}
}
十五,while循环
环语句就是让计算机根据条件做循环计算,在条件满足时继续循环,条件不满足时退出循环。
例如,计算从1到100的和:
1 + 2 + 3 + 4 + … + 100 = ?
除了用数列公式外,完全可以让计算机做100次循环累加。因为计算机的特点是计算速度非常快,我们让计算机循环一亿次也用不到1秒,所以很多计算的任务,人去算是算不了的,但是计算机算,使用循环这种简单粗暴的方法就可以快速得到结果。
**我们先看Java提供的
while
条件循环。它的基本用法是:**
while (条件表达式) {
循环语句
}
// 继续执行后续代码
**
while
循环在每次循环开始前,首先判断条件是否成立。如果计算结果为
true
,就把循环体内的语句执行一遍,如果计算结果为
false
,那就直接跳到
while
循环的末尾,继续往下执行。**
我们用while循环来累加1到100,可以这么写:
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
}
}
**注意到
while
循环是先判断循环条件,再循环,因此,有可能一次循环都不做。**
对于循环条件判断,以及自增变量的处理,要特别注意边界条件。思考一下下面的代码为何没有获得正确结果:
public class Main {
public static void main(String[] args) {
int sum = 0;
int n = 0;
while (n <= 100) {
n ++;
sum = sum + n;
}
System.out.println(sum);
}
}
如果循环条件永远满足,那这个循环就变成了死循环。死循环将导致100%的CPU占用,用户会感觉电脑运行缓慢,所以要避免编写死循环代码。
如果循环条件的逻辑写得有问题,也会造成意料之外的结果:
public class Main {
public static void main(String[] args) {
int sum = 0;
int n = 1;
while (n > 0) {
sum = sum + n;
n ++;
}
System.out.println(n); // -2147483648
System.out.println(sum);
}
}
**表面上看,上面的
while
循环是一个死循环,但是,Java的
int
类型有最大值,达到最大值后,再加1会变成负数,结果,意外退出了
while
循环**
十六,do while循环
在Java中,
while
循环是先判断循环条件,再执行循环。而另一种
do while
循环则是先执行循环,再判断条件,条件满足时继续循环,条件不满足时退出。它的用法是:
do {
执行循环语句
} while (条件表达式);
可见,
do while
循环会至少循环一次。
我们把对1到100的求和用
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);
}
}
使用
do while
循环时,同样要注意循环条件的判断
十七,for循环
**除了
while
和
do while
循环,Java使用最广泛的是
for
循环。**
**
for
循环的功能非常强大,它使用计数器实现循环。
for
循环会先初始化计数器,然后,在每次循环前检测循环条件,在每次循环后更新计数器。计数器变量通常命名为
i
。**
**我们把1到100求和用
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);
}
}
**在
for
循环执行前,会先执行初始化语句
int i=1
,它定义了计数器变量
i
并赋初始值为
1
,然后,循环前先检查循环条件
i<=100
,循环后自动执行
i++
,因此,和
while
循环相比,
for
循环把更新计数器的代码统一放到了一起。在
for
循环的循环体内部,不需要去更新变量
i
。**
**因此,
for
循环的用法是:**
for (初始条件; 循环检测条件; 循环后更新计数器) {
// 执行语句
}
**如果我们要对一个整型数组的所有元素求和,可以用
for
循环实现:**
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
int sum = 0;
for (int i=0; i<ns.length; i++) {
System.out.println("i = " + i + ", ns[i] = " + ns[i]);
sum = sum + ns[i];
}
System.out.println("sum = " + sum);
}
}
上面代码的循环条件是
i<ns.length
。因为
ns
数组的长度是
5
,因此,当循环
5
次后,
i
的值被更新为
5
,就不满足循环条件,因此
for
循环结束。
**注意
for
循环的初始化计数器总是会被执行,并且
for
循环也可能循环0次。**
**使用
for
循环时,千万不要在循环体内修改计数器!在循环体中修改计数器常常导致莫名其妙的逻辑错误。对于下面的代码:**
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
System.out.println(ns[i]);
i = i + 1;
}
}
}
虽然不会报错,但是,数组元素只打印了一半,原因是循环内部的
i = i + 1
导致了计数器变量每次循环实际上加了
2
(因为
for
循环还会自动执行
i++
)。因此,在
for
循环中,不要修改计数器的值。计数器的初始化、判断条件、每次循环后的更新条件统一放到
for()
语句中可以一目了然。
如果希望只访问索引为奇数的数组元素,应该把
for
循环改写为:
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i=i+2) {
System.out.println(ns[i]);
}
通过更新计数器的语句
i=i+2
就达到了这个效果,从而避免了在循环体内去修改变量
i
。
使用
for
循环时,计数器变量
i
要尽量定义在
for
循环中:
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
System.out.println(ns[i]);
}
// 无法访问i
int n = i; // compile error!
如果变量
i
定义在
for
循环外:
int[] ns = { 1, 4, 9, 16, 25 };
int i;
for (i=0; i<ns.length; i++) {
System.out.println(ns[i]);
}
// 仍然可以使用i
int n = i;
那么,退出
for
循环后,变量
i
仍然可以被访问,这就破坏了变量应该把访问范围缩到最小的原则。
灵活使用for循环
for
循环还可以缺少初始化语句、循环条件和每次循环更新语句,例如:
// 不设置结束条件:
for (int i=0; ; i++) {
...
}
// 不设置结束条件和更新语句:
for (int i=0; ;) {
...
}
// 什么都不设置:
for (;;) {
...
}
通常不推荐这样写,但是,某些情况下,是可以省略
for
循环的某些语句的。
for each循环
for
循环经常用来遍历数组,因为通过计数器可以根据索引来访问数组的每个元素:
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
System.out.println(ns[i]);
}
但是,很多时候,我们实际上真正想要访问的是数组每个元素的值。Java还提供了另一种
for each
循环,它可以更简单地遍历数组:
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int n : ns) {
System.out.println(n);
}
}
}
**和
for
循环相比,
for each
循环的变量n不再是计数器,而是直接对应到数组的每个元素。
for each
循环的写法也更简洁。但是,
for each
循环无法指定遍历顺序,也无法获取数组的索引。**
**除了数组外,
for each
循环能够遍历所有“可迭代”的数据类型,包括后面会介绍的
List
、
Map
等。**
十八,break和continue
无论是
while
循环还是
for
循环,有两个特别的语句可以使用,就是
break
语句和
continue
语句。
1.break
在循环过程中,可以使用
break
语句跳出当前循环。我们来看一个例子:
public class Main {
public static void main(String[] args) {
int sum = 0;
for (int i=1; ; i++) {
sum = sum + i;
if (i == 100) {
break;
}
}
System.out.println(sum);
}
}
**使用
for
循环计算从1到100时,我们并没有在
for()
中设置循环退出的检测条件。但是,在循环内部,我们用
if
判断,如果
i==100
,就通过
break
退出循环。**
**因此,
break
语句通常都是配合
if
语句使用。要特别注意,
break
语句总是跳出自己所在的那一层循环。例如:**
public class Main {
public static void main(String[] args) {
for (int i=1; i<=10; i++) {
System.out.println("i = " + i);
for (int j=1; j<=10; j++) {
System.out.println("j = " + j);
if (j >= i) {
break;
}
}
// break跳到这里
System.out.println("breaked");
}
}
}
上面的代码是两个
for
循环嵌套。因为
break
语句位于内层的
for
循环,因此,它会跳出内层
for
循环,但不会跳出外层
for
循环。
2.continue
break
会跳出当前循环,也就是整个循环都不会执行了。而
continue
则是提前结束本次循环,直接继续执行下次循环。我们看一个例子:
public class Main {
public static void main(String[] args) {
int sum = 0;
for (int i=1; i<=10; i++) {
System.out.println("begin i = " + i);
if (i % 2 == 0) {
continue; // continue语句会结束本次循环
}
sum = sum + i;
System.out.println("end i = " + i);
}
System.out.println(sum); // 25
}
}
**注意观察
continue
语句的效果。当
i
为奇数时,完整地执行了整个循环,因此,会打印
begin i=1
和
end i=1
。在i为偶数时,
continue
语句会提前结束本次循环,因此,会打印
begin i=2
但不会打印
end i = 2
。**
**在多层嵌套的循环中,
continue
语句同样是结束本次自己所在的循环。**
小结
break
语句可以跳出当前循环;
break
语句通常配合
if
,在满足条件时提前结束整个循环;
break
语句总是跳出最近的一层循环;
continue
语句可以提前结束本次循环;
continue
语句通常配合
if
,在满足条件时提前结束本次循环。
十九,数组
1.遍历数组
通过 for 循环就可以遍历数组。因为数组的每个元素都可以通过索引来访问,因此,使用标准的 for 循环可以完成一个数组的遍历:
public class S {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int i=0; i<ns.length; i++) {
int n = ns[i];
System.out.println(n);
}
}
}
为了实现 for 循环遍历,初始条件为i=0,因为索引总是从0开始,继续循环的条件为 i<ns.length ,因为当 i=ns.length 时,i已经超出了索引范围(索引范围是0 ~ ns.length-1),每次循环后, i++。
第二种方式是使用for each循环,直接迭代数组的每个元素:
public class S {
public static void main(String[] args) {
int[] ns = {1, 4, 9, 16, 25};
for (int n: ns){
System.out.println(n);
}
}
}
注意:在for (int n : ns)循环中,变量n直接拿到ns数组的元素,而不是索引。
显然for each循环更加简洁。但是,for each循环无法拿到数组的索引,因此,到底用哪一种for循环,取决于我们的需要。
2.打印数组内容
直接打印数组变量,得到的是数组在JVM中的引用地址:
int[] ns = { 1, 1, 2, 3, 5, 8 };
System.out.println(ns);
这并没有什么意义,因为我们希望打印的数组的元素内容。因此,使用for each循环来打印它:
int[] ns = { 1, 1, 2, 3, 5, 8 };
for (int n : ns) {
System.out.print(n + ", ");
}
3.快速打印数组内容Arrays.toString()
使用for each循环打印也很麻烦。幸好Java标准库提供了Arrays.toString(),可以快速打印数组内容:
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 1, 2, 3, 5, 8 };
System.out.println(Arrays.toString(ns));
}
}
4.数组排序
对数组进行排序是程序中非常基本的需求。常用的排序算法有冒泡排序、插入排序和快速排序等。
我们来看一下如何使用冒泡排序算法对一个整型数组从小到大进行排序:
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[] ns = { 28, 12, 89, 73, 65, 18, 96, 50, 8, 36 };
// 排序前:
System.out.println(Arrays.toString(ns));
for (int i = 0; i < ns.length - 1; i++) {
for (int j = 0; j < ns.length - i - 1; j++) {
if (ns[j] > ns[j+1]) {
// 交换ns[j]和ns[j+1]:
int tmp = ns[j];
ns[j] = ns[j+1];
ns[j+1] = tmp;
}
}
}
// 排序后:
System.out.println(Arrays.toString(ns));
}
}
冒泡排序的特点是,每一轮循环后,最大的一个数被交换到末尾,因此,下一轮循环就可以“刨除”最后的数,每一轮循环都比上一轮循环的结束位置靠前一位。
另外,注意到交换两个变量的值必须借助一个临时变量。像这么写是错误的:
int x = 1;
int y = 2;
x = y; // x现在是2
y = x; // y现在还是2
正确的写法是:
int x = 1;
int y = 2;
int t = x; // 把x的值保存在临时变量t中, t现在是1
x = y; // x现在是2
y = t; // y现在是t的值1
5.排序功能Arrays.sort()
实际上,Java的标准库已经内置了排序功能,我们只需要调用JDK提供的Arrays.sort()就可以排序:
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[] ns = { 28, 12, 89, 73, 65, 18, 96, 50, 8, 36 };
Arrays.sort(ns);
System.out.println(Arrays.toString(ns));
}
}
6.原理:
必须注意,对数组排序实际上修改了数组本身。例如,排序前的数组是:
int[] ns = { 9, 3, 6, 5 };
在内存中,这个整型数组表示如下:
┌───┬───┬───┬───┐
ns───>│ 9 │ 3 │ 6 │ 5 │
└───┴───┴───┴───┘
当我们调用
Arrays.sort(ns);
后,这个整型数组在内存中变为:
┌───┬───┬───┬───┐
ns───>│ 3 │ 5 │ 6 │ 9 │
└───┴───┴───┴───┘
即变量
ns
指向的数组内容已经被改变了。
如果对一个字符串数组进行排序,例如:
String[] ns = { "banana", "apple", "pear" };
排序前,这个数组在内存中表示如下:
┌──────────────────────────────────┐
┌───┼──────────────────────┐ │
│ │ ▼ ▼
┌───┬─┴─┬─┴─┬───┬────────┬───┬───────┬───┬──────┬───┐
ns ─────>│░░░│░░░│░░░│ │"banana"│ │"apple"│ │"pear"│ │
└─┬─┴───┴───┴───┴────────┴───┴───────┴───┴──────┴───┘
│ ▲
└─────────────────┘
调用
Arrays.sort(ns);
排序后,这个数组在内存中表示如下:
┌──────────────────────────────────┐
┌───┼──────────┐ │
│ │ ▼ ▼
┌───┬─┴─┬─┴─┬───┬────────┬───┬───────┬───┬──────┬───┐
ns ─────>│░░░│░░░│░░░│ │"banana"│ │"apple"│ │"pear"│ │
└─┬─┴───┴───┴───┴────────┴───┴───────┴───┴──────┴───┘
│ ▲
└──────────────────────────────┘
原来的3个字符串在内存中均没有任何变化,但是
ns
数组的每个元素指向变化了。
7.小结
常用的排序算法有冒泡排序、插入排序和快速排序等;
**冒泡排序使用两层
for
循环实现排序;**
交换两个变量的值需要借助一个临时变量。
**可以直接使用Java标准库提供的
Arrays.sort()
进行排序;**
对数组排序会直接修改数组本身。
二十,二维数组
1.介绍:
二维数组就是数组的数组。定义一个二维数组如下:
public class Main {
public static void main(String[] args) {
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
System.out.println(ns.length); // 3
}
}
运算结果
3
因为
ns
包含3个数组,因此,
ns.length
为
3
。实际上
ns
在内存中的结构如下:
┌───┬───┬───┬───┐
┌───┐ ┌──>│ 1 │ 2 │ 3 │ 4 │
ns ─────>│░░░│──┘ └───┴───┴───┴───┘
├───┤ ┌───┬───┬───┬───┐
│░░░│─────>│ 5 │ 6 │ 7 │ 8 │
├───┤ └───┴───┴───┴───┘
│░░░│──┐ ┌───┬───┬───┬───┐
└───┘ └──>│ 9 │10 │11 │12 │
└───┴───┴───┴───┘
如果我们定义一个普通数组
arr0
,然后把
ns[0]
赋值给它:
public class Main {
public static void main(String[] args) {
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
int[] arr0 = ns[0];
System.out.println(arr0.length);
}
}
运算结果
4
实际上
arr0
就获取了
ns
数组的第0个元素。因为
ns
数组的每个元素也是一个数组,因此,
arr0
指向的数组就是
{ 1, 2, 3, 4 }
。在内存中,结构如下:
arr0 ─────┐
▼
┌───┬───┬───┬───┐
┌───┐ ┌──>│ 1 │ 2 │ 3 │ 4 │
ns ─────>│░░░│──┘ └───┴───┴───┴───┘
├───┤ ┌───┬───┬───┬───┐
│░░░│─────>│ 5 │ 6 │ 7 │ 8 │
├───┤ └───┴───┴───┴───┘
│░░░│──┐ ┌───┬───┬───┬───┐
└───┘ └──>│ 9 │10 │11 │12 │
└───┴───┴───┴───┘
访问二维数组的某个元素需要使用
array[row][col]
,例如:
System.out.println(ns[1][2]); // 7
**二维数组的每个数组元素的长度并不要求相同,例如,可以这么定义
ns
数组:**
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6 },
{ 7, 8, 9 }
};
这个二维数组在内存中的结构如下:
┌───┬───┬───┬───┐
┌───┐ ┌──>│ 1 │ 2 │ 3 │ 4 │
ns ─────>│░░░│──┘ └───┴───┴───┴───┘
├───┤ ┌───┬───┐
│░░░│─────>│ 5 │ 6 │
├───┤ └───┴───┘
│░░░│──┐ ┌───┬───┬───┐
└───┘ └──>│ 7 │ 8 │ 9 │
└───┴───┴───┘
2.打印二维数组------for循环
要打印一个二维数组,可以使用两层嵌套的for循环:
public class S {
public static void main(String[] args) {
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
for (int[] arr : ns) {
for (int n : arr) {
System.out.print(n);
System.out.print(',');
}
}
}
}
3.打印二维数组------Arrays.deepToString()
或者使用Java标准库的Arrays.deepToString()
但是输出的话会和上面方法一的格式有些差别
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[][] ns = {
{ 1, 2, 3, 4 },
{ 5, 6, 7, 8 },
{ 9, 10, 11, 12 }
};
System.out.println(Arrays.deepToString(ns));
}
}
二十一,三维数组
三维数组就是二维数组的数组。可以这么定义一个三维数组:
int[][][] ns = {
{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
},
{
{10, 11},
{12, 13}
},
{
{14, 15, 16},
{17, 18}
}
};
它在内存中的结构如下:
┌───┬───┬───┐
┌───┐ ┌──>│ 1 │ 2 │ 3 │
┌──>│░░░│──┘ └───┴───┴───┘
│ ├───┤ ┌───┬───┬───┐
│ │░░░│─────>│ 4 │ 5 │ 6 │
│ ├───┤ └───┴───┴───┘
│ │░░░│──┐ ┌───┬───┬───┐
┌───┐ │ └───┘ └──>│ 7 │ 8 │ 9 │
ns ────>│░░░│──┘ └───┴───┴───┘
├───┤ ┌───┐ ┌───┬───┐
│░░░│─────>│░░░│─────>│10 │11 │
├───┤ ├───┤ └───┴───┘
│░░░│──┐ │░░░│──┐ ┌───┬───┐
└───┘ │ └───┘ └──>│12 │13 │
│ └───┴───┘
│ ┌───┐ ┌───┬───┬───┐
└──>│░░░│─────>│14 │15 │16 │
├───┤ └───┴───┴───┘
│░░░│──┐ ┌───┬───┐
└───┘ └──>│17 │18 │
└───┴───┘
如果我们要访问三维数组的某个元素,例如,n[2] [0] [1],只需要顺着定位找到对应的最终元素15即可。**
理论上,我们可以定义任意的N维数组。但在实际应用中,除了二维数组在某些时候还能用得上,更高维度的数组很少使用。
二十三,命令行参数
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
这样,程序就可以根据传入的命令行参数,作出不同的响应。
面向对象基础
一,面向对象基础
1.简介
面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
现实世界中,我们定义了“人”这种抽象概念,而具体的人则是“小明”、“小红”、“小军”等一个个具体的人。所以,“人”可以定义为一个类(class),而具体的人则是实例(instance):
现实世界计算机模型Java代码人类 / classclass Person { }小明实例 / mingPerson ming = new Person()小红实例 / hongPerson hong = new Person()小军实例 / junPerson jun = new Person()
同样的,“书”也是一种抽象的概念,所以它是类,而《Java核心技术》、《Java编程思想》、《Java学习笔记》则是实例:
现实世界计算机模型Java代码书类 / classclass Book { }Java核心技术实例 / book1Book book1 = new Book()Java编程思想实例 / book2Book book2 = new Book()Java学习笔记实例 / book3Book book3 = new Book()
2.class和instance
所以,只要理解了class和instance的概念,基本上就明白了什么是面向对象编程。
class是一种对象模版,它定义了如何创建实例,因此,class本身就是一种数据类型:
而instance是对象实例,instance是根据class创建的实例,可以创建多个instance,每个instance类型相同,但各自属性可能不相同:
3.定义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;
}
4.创建实例
定义了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;
上述两个变量分别指向两个不同的实例,它们在内存中的结构如下:
┌──────────────────┐
ming ──────>│Person instance │
├──────────────────┤
│name = "Xiao Ming"│
│age = 12 │
└──────────────────┘
┌──────────────────┐
hong ──────>│Person instance │
├──────────────────┤
│name = "Xiao Hong"│
│age = 15 │
└──────────────────┘
两个
instance
拥有
class
定义的
name
和
age
字段,且各自都有一份独立的数据,互不干扰。
二,方法
1.简介
**一个
class
可以包含多个
field
,例如,我们给
Person
类就定义了两个
field
:**
class Person {
public String name;
public int age;
}
**但是,直接把
field
用
public
暴露给外部可能会破坏封装性。比如,代码可以这样写:**
Person ming = new Person();
ming.name = "Xiao Ming";
ming.age = -99; // age设置为负数
**显然,直接操作
field
,容易造成逻辑混乱。为了避免外部代码直接去访问
field
,我们可以用
private
修饰
field
,拒绝外部访问:**
class Person {
private String name;
private int age;
}
**试试
private
修饰的
field
有什么效果:**
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.name = "Xiao Ming"; // 对字段name赋值
ming.age = 12; // 对字段age赋值
}
}
class Person {
private String name;
private int age;
}
**是不是编译报错?把访问
field
的赋值语句去了就可以正常编译了**
把field从public改成private,外部代码不能访问这些field,
那我们定义这些field有什么用?
怎么才能给它赋值?
怎么才能读取它的值?
所以我们需要使用方法(method)来让外部代码可以间接修改field:
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setName("Xiao Ming"); // 设置name
ming.setAge(12); // 设置age
System.out.println(ming.getName() + ", " + ming.getAge());
}
}
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) {
if (age < 0 || age > 100) {
throw new IllegalArgumentException("invalid age value");
}
this.age = age;
}
}
虽然外部代码不能直接修改private字段,但是,外部代码可以调用方法setName()和setAge()来间接修改private字段。在方法内部,我们就有机会检查参数对不对。比如,setAge()就会检查传入的参数,参数超出了范围,直接报错。这样,外部代码就没有任何机会把age设置成不合理的值。
对setName()方法同样可以做检查,例如,不允许传入null和空字符串:
public void setName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("invalid name");
}
this.name = name.strip(); // 去掉首尾空格
}
同样,外部代码不能直接读取private字段,
但可以通过getName()和getAge()间接获取private字段的值。
所以,一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。
调用方法的语法是实例变量.方法名(参数);。一个方法调用就是一个语句,所以不要忘了在末尾加;。例如:ming.setName("Xiao Ming");。
2.定义方法
从上面的代码可以看出,定义方法的语法是:
修饰符 方法返回类型 方法名(方法参数列表) {
若干方法语句;
return 方法返回值;
}
方法返回值通过
return
语句实现,如果没有返回值,返回类型设置为
void
,可以省略
return
。
3.private方法
有public方法,自然就有private方法。和private字段一样,private方法不允许外部调用,那我们定义private方法有什么用?
定义private方法的理由是内部方法是可以调用private方法的。例如:
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setBirth(2008);
System.out.println(ming.getAge());
}
}
class Person {
private String name;
private int birth;
public void setBirth(int birth) {
this.birth = birth;
}
public int getAge() {
return calcAge(2019); // 调用private方法
}
// private方法:
private int calcAge(int currentYear) {
return currentYear - this.birth;
}
}
观察上述代码,calcAge()是一个private方法,外部代码无法调用,但是,内部方法getAge()可以调用它。
此外,我们还注意到,这个Person类只定义了birth字段,没有定义age字段,获取age时,通过方法getAge()返回的是一个实时计算的值,并非存储在某个字段的值。这说明方法可以封装一个类的对外接口,调用方不需要知道也不关心Person实例在内部到底有没有age字段。
4.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了
}
}
5.方法参数
方法可以包含0个或任意个参数。方法参数用于接收传递给方法的变量值。调用方法时,必须严格按照参数的定义一一传递。例如:
class Person {
...
public void setNameAndAge(String name, int age) {
...
}
}
调用这个
setNameAndAge()
方法时,必须有两个参数,且第一个参数必须为
String
,第二个参数必须为
int
:
Person ming = new Person();
ming.setNameAndAge("Xiao Ming"); // 编译错误:参数个数不对
ming.setNameAndAge(12, "Xiao Ming"); // 编译错误:参数类型不对
6.可变参数
可变参数用
类型...
定义,可变参数相当于数组类型:
class Group {
private String[] names;
public void setNames(String... names) {
this.names = names;
}
}
上面的
setNames()
就定义了一个可变参数。调用时,可以这么写:
Group g = new Group();
g.setNames("Xiao Ming", "Xiao Hong", "Xiao Jun"); // 传入3个String
g.setNames("Xiao Ming", "Xiao Hong"); // 传入2个String
g.setNames("Xiao Ming"); // 传入1个String
g.setNames(); // 传入0个String
完全可以把可变参数改写为
String[]
类型:
class Group {
private String[] names;
public void setNames(String[] names) {
this.names = names;
}
}
但是,调用方需要自己先构造
String[]
,比较麻烦。例如:
Group g = new Group();
g.setNames(new String[] {"Xiao Ming", "Xiao Hong", "Xiao Jun"}); // 传入1个String[]
另一个问题是,调用方可以传入
null
:
Group g = new Group();
g.setNames(null);
而可变参数可以保证无法传入
null
,因为传入0个参数时,接收到的实际值是一个空数组而不是
null
。
7.参数绑定
调用方把参数传递给实例方法时,调用时传递的值会按参数位置一一绑定。
那什么是参数绑定?
我们先观察一个基本类型参数的传递:
public class Main {
public static void main(String[] args) {
Person p = new Person();
int n = 15; // n的值为15
p.setAge(n); // 传入n的值
System.out.println(p.getAge()); // 15
n = 20; // n的值改为20
System.out.println(p.getAge()); // 15还是20?
}
}
class Person {
private int age;
public int getAge() {
return this.age;
}
public void setAge(int age) {
this.age = age;
}
}
**运行代码,从结果可知,修改外部的局部变量
n
,不影响实例
p
的
age
字段,原因是
setAge()
方法获得的参数,复制了
n
的值,因此,
p.age
和局部变量
n
互不影响。**
结论:基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。
我们再看一个传递引用参数的例子:
public class Main {
public static void main(String[] args) {
Person p = new Person();
String[] fullname = new String[] { "Homer", "Simpson" };
p.setName(fullname); // 传入fullname数组
System.out.println(p.getName()); // "Homer Simpson"
fullname[0] = "Bart"; // fullname数组的第一个元素修改为"Bart"
System.out.println(p.getName()); // "Homer Simpson"还是"Bart Simpson"?
}
}
class Person {
private String[] name;
public String getName() {
return this.name[0] + " " + this.name[1];
}
public void setName(String[] name) {
this.name = name;
}
}
注意到setName()的参数现在是一个数组。一开始,把fullname数组传进去,然后,修改fullname数组的内容,结果发现,实例p的字段p.name也被修改了!
结论:引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛)。
有了上面的结论,我们再看一个例子:
public class Main {
public static void main(String[] args) {
Person p = new Person();
String bob = "Bob";
p.setName(bob); // 传入bob变量
System.out.println(p.getName()); // "Bob"
bob = "Alice"; // bob改名为Alice
System.out.println(p.getName()); // "Bob"还是"Alice"?
}
}
class Person {
private String name;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
}
三,构造方法
1.简介
创建实例的时候,我们经常需要同时初始化这个实例的字段,例如:
Person ming = new Person();
ming.setName("小明");
ming.setAge(12);
初始化对象实例需要3行代码,而且,如果忘了调用
setName()
或者
setAge()
,这个实例内部的状态就是不正确的。
能否在创建对象实例时就把内部字段全部初始化为合适的值?
完全可以。
这时,我们就需要构造方法。
建实例的时候,实际上是通过构造方法来初始化实例的。我们先来定义一个构造方法,能在创建
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;
}
}
**由于构造方法是如此特殊,所以构造方法的名称就是类名。构造方法的参数没有限制,在方法内部,也可以编写任意语句。但是,和普通方法相比,构造方法没有返回值(也没有
void
),调用构造方法,必须用
new
操作符。**
2.默认构造方法
是不是任何
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;
}
}
如果既要能使用带参数的构造方法,又想保留不带参数的构造方法,那么只能把两个构造方法都定义出来:
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)
的字段值最终由构造方法的代码确定。
3.多构造方法
可以定义多个构造方法,在通过
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)
}
}
四,方法重载
在一个类中,我们可以定义多个方法。如果有一系列方法,它们的功能都是类似的,只有参数有所不同,那么,可以把这一组方法名做成同名方法。例如,在
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)
根据字符串查找,但指定起始位置。
public class S {
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);
}
}
)
五,继承
1.简介:
在前面的章节中,我们已经定义了
Person
类:
class Person {
private String name;
private int age;
public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
}
现在,假设需要定义一个
Student
类,字段如下:
class Student {
private String name;
private int age;
private int score;
public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
public int getScore() { … }
public void setScore(int score) { … }
}
仔细观察,发现
Student
类包含了
Person
类已有的字段和方法,只是多出了一个
score
字段和相应的
getScore()
、
setScore()
方法。
能不能在
Student
中不要写重复的代码?
这个时候,继承就派上用场了。
继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。当我们让
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)。
2.继承树
注意到我们在定义
Person
的时候,没有写
extends
。在Java中,没有明确写
extends
的类,编译器会自动加上
extends Object
。所以,任何类,除了
Object
,都会继承自某个类。下图是
Person
、
Student
的继承树:
┌───────────┐
│ Object │
└───────────┘
▲
│
┌───────────┐
│ Person │
└───────────┘
▲
│
┌───────────┐
│ Student │
└───────────┘
Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有
Object
特殊,它没有父类。
类似的,如果我们定义一个继承自
Person
的
Teacher
,它们的继承树关系如下:
┌───────────┐
│ Object │
└───────────┘
▲
│
┌───────────┐
│ Person │
└───────────┘
▲ ▲
│ │
│ │
┌───────────┐ ┌───────────┐
│ Student │ │ Teacher │
└───────────┘ └───────────┘
3.protected
继承有个特点,就是子类无法访问父类的
private
字段或者
private
方法。例如,
Student
类就无法访问
Person
类的
name
和
age
字段:
class Person {
private String name;
private int age;
}
class Student extends Person {
public String hello() {
return "Hello, " + name; // 编译错误:无法访问name字段
}
}
这使得继承的作用被削弱了。为了让子类可以访问父类的字段,我们需要把
private
改为
protected
。用
protected
修饰的字段可以被子类访问:
class Person {
protected String name;
protected int age;
}
class Student extends Person {
public String hello() {
return "Hello, " + name; // OK!
}
}
因此,
protected
关键字可以把字段和方法的访问权限控制在继承树内部,一个
protected
字段和方法可以被其子类,以及子类的子类所访问,后面我们还会详细讲解。
4.super
super
关键字表示父类(超类)。子类引用父类的字段时,可以用
super.fieldName
。例如:
class Student extends Person {
public String hello() {
return "Hello, " + super.name;
}
}
实际上,这里使用
super.name
,或者
this.name
,或者
name
,效果都是一样的。编译器会自动定位到父类的
name
字段。
但是,在某些时候,就必须使用
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;
}
}
运行上面的代码,会得到一个编译错误,大意是在
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()
并给出参数以便让编译器定位到父类的一个合适的构造方法。
这里还顺带引出了另一个问题:即子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。
5.阻止继承
正常情况下,只要某个class没有
final
修饰符,那么任何类都可以从该class继承。
从Java 15开始,允许使用
sealed
修饰class,并通过
permits
明确写出能够从该class继承的子类名称。
例如,定义一个
Shape
类:
public sealed class Shape permits Rect, Circle, Triangle {
...
}
上述
Shape
类就是一个
sealed
类,它只允许指定的3个类继承它。如果写:
public final class Rect extends Shape {...}
是没问题的,因为
Rect
出现在
Shape
的
permits
列表中。但是,如果定义一个
Ellipse
就会报错:
public final class Ellipse extends Shape {...}
// Compile error: class is not allowed to extend sealed class: Shape
原因是
Ellipse
并未出现在
Shape
的
permits
列表中。这种
sealed
类主要用于一些框架,防止继承被滥用。
sealed
类在Java 15中目前是预览状态,要启用它,必须使用参数
--enable-preview
和
--source 15
。
6.向上转型
如果一个引用变量的类型是
Student
,那么它可以指向一个
Student
类型的实例:
Student s = new Student();
如果一个引用类型的变量是
Person
,那么它可以指向一个
Person
类型的实例:
Person p = new Person();
现在问题来了:如果
Student
是从
Person
继承下来的,那么,一个引用类型为
Person
的变量,能否指向
Student
类型的实例?
Person p = new Student(); // ???
测试一下就可以发现,这种指向是允许的!
这是因为
Student
继承自
Person
,因此,它拥有
Person
的全部功能。
Person
类型的变量,如果指向
Student
类型的实例,对它进行操作,是没有问题的!
这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型(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
。
7.向下转型
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(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; // 一定会成功
}
从Java 14开始,判断
instanceof
后,可以直接转型为指定变量,避免再次强制转型。例如,对于以下代码:
Object obj = "hello";
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.toUpperCase());
}
这种使用
instanceof
的写法更加简洁。
8.区分继承和组合
在使用继承时,我们要注意逻辑一致性。
考察下面的
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关系。
六,多态
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(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
不是必需的。
在上一节中,我们已经知道,引用变量的声明类型可能与其实际类型不符,例如:
Person p = new Student();
现在,我们考虑一种情况,如果子类覆写了父类的方法:
public class Main {
public static void main(String[] args) {
Person p = new Student();
p.run(); // 应该打印Person.run还是Student.run?
}
}
class Person {
public void run() {
System.out.println("Person.run");
}
}
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}
那么,一个实际类型为
Student
,引用类型为
Person
的变量,调用其
run()
方法,调用的是
Person
还是
Student
的
run()
方法?
运行一下上面的代码就可以知道,实际上调用的方法是
Student
的
run()
方法。因此可得出结论:
Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。
这个非常重要的特性在面向对象编程中称之为多态。它的英文拼写非常复杂:Polymorphic。
多态
多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。例如:
Person p = new Student();
p.run(); // 无法确定运行时究竟调用哪个run()方法
有童鞋会问,从上面的代码一看就明白,肯定调用的是
Student
的
run()
方法啊。
但是,假设我们编写这样一个方法:
public void runTwice(Person p) {
p.run();
p.run();
}
它传入的参数类型是
Person
,我们是无法知道传入的参数实际类型究竟是
Person
,还是
Student
,还是
Person
的其他子类,因此,也无法确定调用的是不是
Person
类定义的
run()
方法。
所以,多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。这种不确定性的方法调用,究竟有什么作用?
我们还是来举栗子。
假设我们定义一种收入,需要给它报税,那么先定义一个
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;
}
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;
}
}
**观察
totalTax()
方法:利用多态,
totalTax()
方法只需要和
Income
打交道,它完全不需要知道
Salary
和
StateCouncilSpecialAllowance
的存在,就可以正确计算出总的税。如果我们要新增一种稿费收入,只需要从
Income
派生,然后正确覆写
getTax()
方法就可以。把新的类型传入
totalTax()
,不需要修改任何代码。**
可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。
多态
阅读: 17145662
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(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
Run
但是
@Override
不是必需的。
在上一节中,我们已经知道,引用变量的声明类型可能与其实际类型不符,例如:
Person p = new Student();
现在,我们考虑一种情况,如果子类覆写了父类的方法:
// override
Run
那么,一个实际类型为
Student
,引用类型为
Person
的变量,调用其
run()
方法,调用的是
Person
还是
Student
的
run()
方法?
运行一下上面的代码就可以知道,实际上调用的方法是
Student
的
run()
方法。因此可得出结论:
Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。
这个非常重要的特性在面向对象编程中称之为多态。它的英文拼写非常复杂:Polymorphic。
多态
多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。例如:
Person p = new Student();
p.run(); // 无法确定运行时究竟调用哪个run()方法
有童鞋会问,从上面的代码一看就明白,肯定调用的是
Student
的
run()
方法啊。
但是,假设我们编写这样一个方法:
public void runTwice(Person p) {
p.run();
p.run();
}
它传入的参数类型是
Person
,我们是无法知道传入的参数实际类型究竟是
Person
,还是
Student
,还是
Person
的其他子类,因此,也无法确定调用的是不是
Person
类定义的
run()
方法。
所以,多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。这种不确定性的方法调用,究竟有什么作用?
我们还是来举栗子。
假设我们定义一种收入,需要给它报税,那么先定义一个
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
Run
观察
totalTax()
方法:利用多态,
totalTax()
方法只需要和
Income
打交道,它完全不需要知道
Salary
和
StateCouncilSpecialAllowance
的存在,就可以正确计算出总的税。如果我们要新增一种稿费收入,只需要从
Income
派生,然后正确覆写
getTax()
方法就可以。把新的类型传入
totalTax()
,不需要修改任何代码。
可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。
覆写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();
}
}
调用super
在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过
super
来调用。例如:
class Person {
protected String name;
public String hello() {
return "Hello, " + name;
}
}
Student extends Person {
@Override
public String hello() {
// 调用父类的hello()方法:
return super.hello() + "!";
}
}
final
继承可以允许子类覆写父类的方法。如果一个父类不允许子类对它的某个方法进行覆写,可以把该方法标记为
final
。用
final
修饰的方法不能被
Override
:
class Person {
protected String name;
public final String hello() {
return "Hello, " + name;
}
}
Student extends Person {
// compile error: 不允许覆写
@Override
public String hello() {
}
}
如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为
final
。用
final
修饰的类不能被继承:
final class Person {
protected String name;
}
// compile error: 不允许继承自Person
Student extends Person {
}
对于一个类的实例字段,同样可以用
final
修饰。用
final
修饰的字段在初始化后不能被修改。例如:
class Person {
public final String name = "Unamed";
}
对
final
字段重新赋值会报错:
Person p = new Person();
p.name = "New Name"; // compile error!
可以在构造方法中初始化final字段:
class Person {
public final String name;
public Person(String name) {
this.name = name;
}
}
这种方法更为常用,因为可以保证实例一旦创建,其
final
字段就不可修改。
小结
- 子类可以覆写父类的方法(Override),覆写在子类中改变了父类方法的行为;
- Java的方法调用总是作用于运行期对象的实际类型,这种行为称为多态;
final
修饰符有多种作用:-final
修饰的方法可以阻止被覆写;-final
修饰的class可以阻止被继承;-final
修饰的field必须在创建对象时初始化,随后不可修改。
七,抽象类
1.简介
由于多态的存在,每个子类都可以覆写父类的方法,例如:
class Person {
public void run() { … }
}
class Student extends Person {
@Override
public void run() { … }
}
class Teacher extends Person {
@Override
public void run() { … }
}
从
Person
类派生的
Student
和
Teacher
都可以覆写
run()
方法。
如果父类
Person
的
run()
方法没有实际意义,能否去掉方法的执行语句?
class Person {
public void run(); // Compile Error!
}
答案是不行,会导致编译错误,因为定义方法的时候,必须实现方法的语句。
能不能去掉父类的
run()
方法?
答案还是不行,因为去掉父类的
run()
方法,就失去了多态的特性。例如,
runTwice()
就无法编译:
public void runTwice(Person p) {
p.run(); // Person没有run()方法,会导致编译错误
p.run();
}
如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去覆写它,那么,可以把父类的方法声明为抽象方法:
class Person {
public abstract void run();
}
把一个方法声明为
abstract
,表示它是一个抽象方法,本身没有实现任何方法语句。因为这个抽象方法本身是无法执行的,所以,
Person
类也无法被实例化。编译器会告诉我们,无法编译
Person
类,因为它包含抽象方法。
必须把
Person
类本身也声明为
abstract
,才能正确编译它:
abstract class Person {
public abstract void run();
}
2.抽象类
如果一个
class
定义了方法,但没有具体执行代码,这个方法就是抽象方法,抽象方法用
abstract
修饰。
因为无法执行抽象方法,因此这个类也必须申明为抽象类(abstract class)。
使用
abstract
修饰的类就是抽象类。我们无法实例化一个抽象类:
Person p = new Person(); // 编译错误
无法实例化的抽象类有什么用?
因为抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”。
例如,
Person
类定义了抽象方法
run()
,那么,在实现子类
Student
的时候,就必须覆写
run()
方法:
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");
}
}
面向抽象编程
当我们定义了抽象类
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
); - 不需要子类就可以实现业务逻辑(正常编译);
- 具体的业务逻辑由不同的子类实现,调用者并不关心。
八,接口
1.简介
在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力。
如果一个抽象类没有字段,所有方法全部都是抽象方法:
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
...
}
2.术语
注意区分术语:
Java的接口特指
interface
的定义,表示一个接口类型和一组方法签名,而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。
抽象类和接口的对比如下:
abstract classinterface继承只能extends一个class可以implements多个interface字段可以定义实例字段不能定义实例字段抽象方法可以定义抽象方法可以定义抽象方法非抽象方法可以定义非抽象方法可以定义default方法
3.接口继承
一个
interface
可以继承自另一个
interface
。
interface
继承自
interface
使用
extends
,它相当于扩展了接口的方法。例如:
interface Hello {
void hello();
}
interface Person extends Hello {
void run();
String getName();
}
此时,
Person
接口继承自
Hello
接口,因此,
Person
接口现在实际上有3个抽象方法签名,其中一个来自继承的
Hello
接口。
4.继承关系
合理设计
interface
和
abstract class
的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在
abstract class
中,具体逻辑放到各个子类,而接口层次代表抽象程度。可以参考Java的集合类定义的一组接口、抽象类以及具体子类的继承关系:
┌───────────────┐
│ Iterable │
└───────────────┘
▲ ┌───────────────────┐
│ │ Object │
┌───────────────┐ └───────────────────┘
│ Collection │ ▲
└───────────────┘ │
▲ ▲ ┌───────────────────┐
│ └──────────│AbstractCollection │
┌───────────────┐ └───────────────────┘
│ List │ ▲
└───────────────┘ │
▲ ┌───────────────────┐
└──────────│ AbstractList │
└───────────────────┘
▲ ▲
│ │
│ │
┌────────────┐ ┌────────────┐
│ ArrayList │ │ LinkedList │
└────────────┘ └────────────┘
在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它,因为接口比抽象类更抽象:
List list = new ArrayList(); // 用List接口引用具体子类的实例
Collection coll = list; // 向上转型为Collection接口
Iterable it = coll; // 向上转型为Iterable接口
5.default方法
在接口中,可以定义
default
方法。例如,把
Person
接口的
run()
方法改为
default
方法:
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;
}
}
**实现类可以不必覆写
default
方法。
default
方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是
default
方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。**
**
default
方法和抽象类的普通方法是有所不同的。因为
interface
没有字段,
default
方法无法访问字段,而抽象类的普通方法可以访问实例字段。**
九,静态字段和静态方法
1.静态字段
在一个
class
中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。
还有一种字段,是用
static
修饰的字段,称为静态字段:
static field
。
实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段。举个例子:
class Person {
public String name;
public int age;
// 定义静态字段number:
public static int number;
}
我们来看看下面的代码:
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;
}
}
对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例:
┌──────────────────┐
ming ──>│Person instance │
├──────────────────┤
│name = "Xiao Ming"│
│age = 12 │
│number ───────────┼──┐ ┌─────────────┐
└──────────────────┘ │ │Person class │
│ ├─────────────┤
├───>│number = 99 │
┌──────────────────┐ │ └─────────────┘
hong ──>│Person instance │ │
├──────────────────┤ │
│name = "Xiao Hong"│ │
│age = 15 │ │
│number ───────────┼──┘
└──────────────────┘
虽然实例可以访问静态字段,但是它们指向的其实都是
Person class
的静态字段。所以,所有实例共享一个静态字段。
因此,不推荐用
实例变量.静态字段
去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为
类名.静态字段
来访问静态对象。
推荐用类名来访问静态字段。可以把静态字段理解为描述
class
本身的字段(非实例字段)。对于上面的代码,更好的写法是:
Person.number = 99;
System.out.println(Person.number);
静态方法
有静态字段,就有静态方法。用
static
修饰的方法称为静态方法。
调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。例如:
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;
}
}
因为静态方法属于
class
而不属于实例,因此,静态方法内部,无法访问
this
变量,也无法访问实例字段,它只能访问静态字段。
通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。
通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告。
静态方法经常用于工具类。例如:
- Arrays.sort()
- Math.random()
静态方法也经常用于辅助方法。注意到Java程序的入口
main()
也是静态方法。
接口的静态字段
因为
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
类型。
Java核心类
一,字符串和编码
1.String
在Java中,
String
是一个引用类型,它本身也是一个
class
。但是,Java编译器对
String
有特殊处理,即可以直接用
"..."
来表示一个字符串:
String s1 = "Hello!";
实际上字符串在
String
内部是通过一个
char[]
数组表示的,因此,按下面的写法也是可以的:
String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'});
因为
String
太常用了,所以Java提供了
"..."
这种字符串字面量表示方法。
Java字符串的一个重要特点就是字符串不可变。这种不可变性是通过内部的
private final char[]
字段,以及没有任何修改
char[]
的方法实现的。
我们来看一个例子:
public class Main {
public static void main(String[] args) {
String s = "Hello";
System.out.println(s);
s = s.toUpperCase();
System.out.println(s);
}
}
2.字符串比较
当我们想要比较两个字符串是否相同时,要特别注意,我们实际上是想比较字符串的内容是否相同。必须使用
equals()
方法而不能用
==
。
我们看下面的例子:
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}
从表面上看,两个字符串用
==
和
equals()
比较都为
true
,但实际上那只是Java编译器在编译期,会自动把所有相同的字符串当作一个对象放入常量池,自然
s1
和
s2
的引用就是相同的。
所以,这种
==
比较返回
true
纯属巧合。换一种写法,
==
比较就会失败:
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "HELLO".toLowerCase();
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}
结论:两个字符串比较,必须总是使用
equals()
方法。
要忽略大小写比较,使用
equalsIgnoreCase()
方法。
String
类还提供了多种方法来搜索子串、提取子串。常用的方法有:
// 是否包含子串:
"Hello".contains("ll"); // true
注意到
contains()
方法的参数是
CharSequence
而不是
String
,因为
CharSequence
是
String
的父类。
搜索子串的更多的例子:
"Hello".indexOf("l"); // 2
"Hello".lastIndexOf("l"); // 3
"Hello".startsWith("He"); // true
"Hello".endsWith("lo"); // true
提取子串的例子:
"Hello".substring(2); // "llo"
"Hello".substring(2, 4); "ll"
注意索引号是从
0
开始的。
3.去除首尾空白字符
使用
trim()
方法可以移除字符串首尾空白字符。空白字符包括空格,
\t
,
\r
,
\n
:
注意:
- \t \r \n\f均是转义字符
- \t 的意思是 :水平制表符。将当前位置移到下一个tab位置。
- \r 的意思是: 回车。将当前位置移到本行的开头。
- \n 的意思是:回车换行。将当前位置移到下一行的开头。
- \f的意思是:换页。将当前位置移到下一页的开头。
" \tHello\r\n ".trim(); // "Hello"
注意:
trim()
并没有改变字符串的内容,而是返回了一个新字符串。
另一个
strip()
方法也可以移除字符串首尾空白字符。它和
trim()
不同的是,类似中文的空格字符
\u3000
也会被移除:
"\u3000Hello\u3000".strip(); // "Hello"
" Hello ".stripLeading(); // "Hello "
" Hello ".stripTrailing(); // " Hello"
4.判断字符串是否为空和空白字符串:
String
还提供了
isEmpty()
和
isBlank()
来判断字符串是否为空和空白字符串:
"".isEmpty(); // true,因为字符串长度为0
" ".isEmpty(); // false,因为字符串长度不为0
" \n".isBlank(); // true,因为只包含空白字符
" Hello ".isBlank(); // false,因为包含非空白字符
5.替换子串s.replace
要在字符串中替换子串,有两种方法。一种是根据字符或字符串替换:
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"
上面的代码通过正则表达式,把匹配的子串统一替换为
","
。关于正则表达式的用法我们会在后面详细讲解。
6.分割字符串
要分割字符串,使用
split()
方法,并且传入的也是正则表达式:
String s = "A,B,C,D";
String[] ss = s.split("\\,"); // {"A", "B", "C", "D"}
7.拼接字符串
拼接字符串使用静态方法
join()
,它用指定的字符串连接字符串数组:
String[] arr = {"A", "B", "C"};
String s = String.join("***", arr); // "A***B***C"
8.格式化字符串
字符串提供了
formatted()
方法和
format()
静态方法,可以传入其他参数,替换占位符,然后生成新的字符串:
public class Main {
public static void main(String[] args) {
String s = "Hi %s, your score is %d!";
System.out.println(s.formatted("Alice", 80));
System.out.println(String.format("Hi %s, your score is %.2f!", "Bob", 59.5));
}
}
运行结果:
Hi Alice, your score is 80!
Hi Bob, your score is 59.50!
有几个占位符,后面就传入几个参数。参数类型要和占位符一致。我们经常用这个方法来格式化信息。常用的占位符有:
%s
:显示字符串;%d
:显示整数;%x
:显示十六进制整数;%f
:显示浮点数。
占位符还可以带格式,例如
%.2f
表示显示两位小数。如果你不确定用啥占位符,那就始终用
%s
,因为
%s
可以显示任何数据类型。要查看完整的格式化语法,请参考JDK文档。
9.类型转换
要把任意基本类型或引用类型转换为字符串,可以使用静态方法
valueOf()
。这是一个重载方法,编译器会根据参数自动选择合适的方法:
String.valueOf(123); // "123"
String.valueOf(45.67); // "45.67"
String.valueOf(true); // "true"
String.valueOf(new Object()); // 类似java.lang.Object@636be97c
要把字符串转换为其他类型,就需要根据情况。例如,把字符串转换为
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
要特别注意,
Integer
有个
getInteger(String)
方法,它不是将字符串转换为
int
,而是把该字符串对应的系统变量转换为
Integer
:
Integer.getInteger("java.version"); // 版本号,11
10.转换为char[]
String
和
char[]
类型可以互相转换,方法是:
char[] cs = "Hello".toCharArray(); // String -> char[]
String s = new String(cs); // char[] -> String
如果修改了
char[]
数组,
String
并不会改变:
public class Main {
public static void main(String[] args) {
char[] cs = "Hello".toCharArray();
String s = new String(cs);
System.out.println(s);
cs[0] = 'X';
System.out.println(s);
}
}
这是因为通过
new String(char[])
创建新的
String
实例时,它并不会直接引用传入的
char[]
数组,而是会复制一份,所以,修改外部的
char[]
数组不会影响
String
实例内部的
char[]
数组,因为这是两个不同的数组。
从
String
的不变性设计可以看出,如果传入的对象有可能改变,我们需要复制而不是直接引用。
例如,下面的代码设计了一个
Score
类保存一组学生的成绩:
public class Main {
public static void main(String[] args) {
int[] scores = new int[] { 88, 77, 51, 66 };
Score s = new Score(scores);
s.printScores();
scores[2] = 99;
s.printScores();
}
}
class Score {
private int[] scores;
public Score(int[] scores) {
this.scores = scores;
}
public void printScores() {
System.out.println(Arrays.toString(scores));
}
}
观察两次输出,由于
Score
内部直接引用了外部传入的
int[]
数组,这会造成外部代码对
int[]
数组的修改,影响到
Score
类的字段。如果外部代码不可信,这就会造成安全隐患。
请修复
Score
的构造方法,使得外部代码对数组的修改不影响
Score
实例的
int[]
字段。
11.字符编码
在早期的计算机系统中,为了给字符编码,美国国家标准学会(American National Standard Institute:ANSI)制定了一套英文字母、数字和常用符号的编码,它占用一个字节,编码范围从
0
到
127
,最高位始终为
0
,称为
ASCII
编码。例如,字符
'A'
的编码是
0x41
,字符
'1'
的编码是
0x31
。
如果要把汉字也纳入计算机编码,很显然一个字节是不够的。
GB2312
标准使用两个字节表示一个汉字,其中第一个字节的最高位始终为
1
,以便和
ASCII
编码区分开。例如,汉字
'中'
的
GB2312
编码是
0xd6d0
。
类似的,日文有
Shift_JIS
编码,韩文有
EUC-KR
编码,这些编码因为标准不统一,同时使用,就会产生冲突。
为了统一全球所有语言的编码,全球统一码联盟发布了
Unicode
编码,它把世界上主要语言都纳入同一个编码,这样,中文、日文、韩文和其他语言就不会冲突。
Unicode
编码需要两个或者更多字节表示,我们可以比较中英文字符在
ASCII
、
GB2312
和
Unicode
的编码:
英文字符
'A'
的
ASCII
编码和
Unicode
编码:
┌────┐
ASCII: │ 41 │
└────┘
┌────┬────┐
Unicode: │ 00 │ 41 │
└────┴────┘
英文字符的
Unicode
编码就是简单地在前面添加一个
00
字节。
中文字符
'中'
的
GB2312
编码和
Unicode
编码:
┌────┬────┐
GB2312: │ d6 │ d0 │
└────┴────┘
┌────┬────┐
Unicode: │ 4e │ 2d │
└────┴────┘
那我们经常使用的
UTF-8
又是什么编码呢?因为英文字符的
Unicode
编码高字节总是
00
,包含大量英文的文本会浪费空间,所以,出现了
UTF-8
编码,它是一种变长编码,用来把固定长度的
Unicode
编码变成1~4字节的变长编码。通过
UTF-8
编码,英文字符
'A'
的
UTF-8
编码变为
0x41
,正好和
ASCII
码一致,而中文
'中'
的
UTF-8
编码为3字节
0xe4b8ad
。
UTF-8
编码的另一个好处是容错能力强。如果传输过程中某些字符出错,不会影响后续字符,因为
UTF-8
编码依靠高字节位来确定一个字符究竟是几个字节,它经常用来作为传输编码。
在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编码表示。
12.延伸阅读
对于不同版本的JDK,
String
类在内存中有不同的优化方式。具体来说,早期JDK版本的
String
总是以
char[]
存储,它的定义如下:
public final class String {
private final char[] value;
private final int offset;
private final int count;
}
而较新的JDK版本的
String
则以
byte[]
存储:如果
String
仅包含ASCII字符,则每个
byte
存储一个字符,否则,每两个
byte
存储一个字符,这样做的目的是为了节省内存,因为大量的长度较短的
String
通常仅包含ASCII字符:
public final class String {
private final byte[] value;
private final byte coder; // 0 = LATIN1, 1 = UTF16
对于使用者来说,
String
内部的优化不影响任何已有代码,因为它的
public
方法签名是不变的。
****小结
- Java字符串
String
是不可变对象; - 字符串操作不改变原字符串内容,而是返回新字符串;
- 常用的字符串操作:提取子串、查找、替换、大小写转换等;
- Java使用Unicode编码表示
String
和char
; - 转换编码就是将
String
和byte[]
转换,需要指定编码; - 转换为
byte[]
时,始终优先考虑UTF-8
编码。
二,StringBuilder
Java编译器对
String
做了特殊处理,使得我们可以直接用
+
拼接字符串。
考察下面的循环代码:
String s = "";
for (int i = 0; i < 1000; i++) {
s = s + "," + i;
}
虽然可以直接拼接字符串,但是,在循环中,每次循环都会创建新的字符串对象,然后扔掉旧的字符串。这样,绝大部分字符串都是临时对象,不但浪费内存,还会影响GC效率。
为了能高效拼接字符串,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());
}
}
常用工具类
1.Math
.1.求绝对值:
Math.abs(-100); // 100
Math.abs(-7.8); // 7.8
.2.取最大或最小值:
Math.max(100, 99); // 100
Math.min(1.2, 2.3); // 1.2
.3.计算xy次方:
Math.pow(2, 10); // 2的10次方=1024
.4.计算√x:
Math.sqrt(2); // 1.414...
.5.计算ex次方:
Math.exp(2); // 7.389...
.6.计算以e为底的对数:
Math.log(4); // 1.386...
.7.计算以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
数学常量PI,e,sinx:
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... 每次都不一样
eg
如果我们要生成一个区间在
[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);
}
}
运行结果1.
26.98111293356738
26
运行结果2.
25.67075129984012
25
运行结果3.
11.507725121648168
11
注:
Java标准库还提供了一个
StrictMath
,它提供了和
Math
几乎一模一样的方法。这两个类的区别在于,由于浮点数计算存在误差,不同的平台(例如x86和ARM)计算的结果可能不一致(指误差不同),因此,
StrictMath
保证所有平台计算结果都是完全相同的,而
Math
会尽量针对平台优化计算速度,所以,绝大多数情况下,使用
Math
就足够了。
2.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
有童鞋问,每次运行程序,生成的随机数都是不同的,没看出伪随机数的特性来。
这是因为我们创建
Random
实例时,如果不给定种子,就使用系统当前时间戳作为种子,因此每次运行时,种子不同,得到的伪随机数序列就不同。
如果我们在创建
Random
实例时指定一个种子,就会得到完全确定的随机数序列:
import java.util.Random;
public class Main {
public static void main(String[] args) {
Random r = new Random(12345);
for (int i = 0; i < 10; i++) {
System.out.println(r.nextInt(100));
}
// 51, 80, 41, 28, 55...
}
}
前面我们使用的
Math.random()
实际上内部调用了
Random
类,所以它也是伪随机数,只是我们无法指定种子。
3.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));
}
}
SecureRandom
的安全性是通过操作系统提供的安全的随机种子来生成随机数。这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”。
在密码学中,安全的随机数非常重要。如果使用不安全的伪随机数,所有加密体系都将被攻破。因此,时刻牢记必须使用
SecureRandom
来产生安全的随机数。
小结
Java提供的常用工具类有:
- Math:数学计算
- Random:生成伪随机数
- SecureRandom:生成安全的随机数
版权归原作者 爱你的阿白~ 所有, 如有侵权,请联系我们删除。