面向对象和面向过程:
面向过程(按照步骤一步一步来):把模型分解成一步一步的步骤,读取文件——>编写——>保存
面向对象:和对象互动,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
GirlFriend gf = new GirlFriend(); //类初始化
gf.name = "Alice";
gf.send("flowers");
面向对象基础
类:class
实例:instance
举例:
现实世界 | 计算机模型 | Java代码 |
---|---|---|
人 | 类 / class | class Person { } |
小明 | 实例 / ming | Person ming = new Person() |
小红 | 实例 / hong | Person hong = new Person() |
class是一种对象模版,它定义了如何创建实例,因此,class本身就是一种数据类型;
而instance是对象实例,instance是根据class创建的实例,可以创建多个instance,每个instance类型相同,但各自属性可能不相同;
class可以当做一个荷包蛋模具,instance可以当做一个个独特的荷包蛋
定义class
在Java中,创建一个类,例如,给这个类命名为Person
,就是定义一个class
:
class Person {
public String name;
public int age;
}
一个class
可以包含多个字段(field
),字段用来描述一个类的特征。
上面的Person
类,我们定义了两个字段,一个是String
类型的字段,命名为name
,一个是int
类型的字段,命名为age
。因此,通过class
,把一组数据汇集到一个对象上,实现了数据封装。
public
是用来修饰字段的,它表示这个字段可以被外部访问。
创造实例
用new操作符。 Person ming = new Person();
Person ming
是定义Person
类型的变量ming
,而new Person()
是创建Person
实例。用 变量.字段 来进行操作和访问。 eg :
ming.name = "Xiao Ming"; // 对字段name赋值
ming.age = 12; // 对字段age赋值
System.out.println(ming.name); // 访问字段name
Person hong = new Person();
hong.name e = "Xiao Hong";
hong.age = 15;
两个instance
拥有class
定义的name
和age
字段,且各自都有一份独立的数据,互不干扰。
一个Java源文件可以包含多个类的定义,但只能定义一个public类,且public类名必须与文件名一致。如果要定义多个public类,必须拆到多个Java源文件中。
小结
- 在OOP中,
class
和instance
是“模版”和“实例”的关系; - 定义
class
就是定义了一种数据类型,对应的instance
是这种数据类型的实例; class
定义的field
,在每个instance
都会拥有各自的field
,且互不干扰;- 通过
new
操作符创建新的instance
,然后用变量指向它,即可通过变量来引用这个instance
; - 访问实例字段的方法是
变量名.字段名
; - 指向
instance
的变量都是引用变量。
方法
方法创建和使用
一个class
可以包含多个field
,例如,我们给Person
类就定义了两个field
;但是直接把field
用public
暴露给外部可能会破坏封装性。
可以用private修饰field,拒绝外部访问。 private String name;
此时不能直接访问field(如 ming.name = “Xiao Ming”; ),使用方法来让外部代码可以间接修改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) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("invalid name");
}
this.name = name.strip(); // 去掉首尾空格
}
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;
}
}
上例中你可以用public方法setName和setAge来修改private字段。采用 实例变量.方法名(参数); 来调用方法语句。如:ming.setName(“Xiao Ming”);
修饰符 方法返回类型 方法名(方法参数列表) {
若干方法语句;
return 方法返回值;
}
另外在类中也可以定义private方法,注意仅可用于该类中
this变量中指向当前实例,因此 this.field可以访问当前实例的字段。(避免存在命名冲突的情况)
方法参数
可以是任意个的参数,用于传递变量。(注意参数的类型和数量)
class Person {
...
public void setNameAndAge(String name, int age) {
...
}
}
或者可以采用可变参数,可变参数用类型...
定义,可变参数相当于数组类型,如:(也可以采用数组,即String[]来改写。)
注意:一个方法的输入中最多有一个可变参数,且要被放置于最后
class Group {
private String[] names;
public void setNames(String... names) {
this.names = names;
}
}
此时setName调用时可以写作:
g.setNames("Xiao Ming", "Xiao Hong", "Xiao Jun"); // 传入3个String
g.setNames("Xiao Ming", "Xiao Hong"); // 传入2个String
g.setNames("Xiao Ming"); // 传入1个String
参数绑定
基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。
而当传递引用类型参数时,把参数传递给实例方法时,调用时传递的值会按参数位置一一绑定。
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()); // 是"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;
}
}
注: 上述实例的属性发生变化,是因为采用的是编辑进行操作,而如果重新赋值(即改变这个变量的指向地址,而不是变量本身),输出就不会发生变化。
fullname = new String[] { "Homer1", "Simpson1" };
System.out.println(p.getName()); //输出将为上一次的结果
小结
- 方法可以让外部代码安全地访问实例字段;
- 方法是一组执行语句,并且可以执行任意逻辑;
- 方法内部遇到return时返回,void表示不返回任何值(注意和返回null不同);
- 外部代码通过public方法操作实例,内部代码可以调用private方法;
- 理解方法的参数绑定。
构建方法
实现在创建对象实例时就把内部字段全部初始化为合适的值
调用时必须用new操作符,且该方法没有返回值
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;
}
}
如果没有人为设定,那么构造方法默认为
public Person() {
}//即为空
如果希望创建时既可以初始化也可以不初始化,可以在构造方法中都定义出来:
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 = "Unamed";//也可以在类内部初始化
private int age = 10;
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;
}
}
当在类内部和构造方法中都进行初始化时,由于先内部初始化再执行构造方法,故最终得到的初始化是构造方法中的。
多个构造方法时,会根据输入参数的数量、位置、类型自动选择,一个构造函数还可以调用另一个构造函数(用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)
}
}
小结
- 实例在创建时通过
new
操作符会调用其对应的构造方法,构造方法用于初始化实例; - 没有定义构造方法时,编译器会自动创建一个默认的无参数构造方法;
- 可以定义多个构造方法,编译器根据参数自动判断;
- 可以在一个构造方法内部调用另一个构造方法,便于代码复用。
方法重构
方法名相同,但各自的参数不同,称为方法重载(Overload
)。返回值通常都相同。
小结
- 方法重载是指多个方法的方法名相同,但各自的参数不同;
- 重载方法应该完成类似的功能,参考
String
的indexOf()
; - 重载方法返回值类型应该相同。
继承
类A 包含大量类B 已有的字段和方法,将类A的方法继承给类B,则类B获得类A 的所有功能**(除了private的),只需要写新增功能。用extends实现:class Student extends Person(Student继承Person)。若用protected**代替,则可以被子类继承和访问。
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) { … }
}
注意:子类自动获得了父类的所有字段,严禁定义与父类重名的字段!
术语中,我们把Person
称为超类(super class),父类(parent class),基类(base class),把Student
称为子类(subclass),扩展类(extended class)。
所有没有特殊extend的类都是Object的子类。Java只允许一个class继承自一个类(但可以有复数个子类),因此,一个类有且仅有一个父类。只有Object
特殊,它没有父类。
使用关键字时用super来实现。如
class Student extends Person {
public String hello() {
return "Hello, " + super.name;//super表示从父类继承
}
}
注:**任何class
的构造方法,第一行语句必须是调用父类的构造方法。**如果没有,编译器自动视为加入一句super();此时若父类没有该调用方法的构造方法则报错。
子类不会继承任何父类的构造方法,子类默认的构造方法是编译器生成的,而不是继承的。
限制继承
用sealed修饰class并通过permits写出能让class继承的子类。
public sealed class Shape permits Rect, Circle, Triangle {
...
} //这里Shape就是一个sealed类,只允许Rect, Circle, Triangle继承
向上转型
即 Person p = new Student(); // 用Student类为模板新建一个Person实例
把子类类型变为父类类型称为向上转型。注意到继承树是Student > Person > Object
,所以,可以把Student
类型转型为Person
,或者更高层次的Object
Student s = new Student();
Person p = s; // upcasting, ok
Object o1 = p; // upcasting, ok
Object o2 = s; // upcasting, ok
向下转型
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型。
Person p1 = new Student(); // upcasting, ok
Person p2 = new Person();
Student s1 = (Student) p1; // ok
Student s2 = (Student) p2; // runtime error! ClassCastException!
本身是子类类型的单位可以再转回,否则会报错(缺少功能)
用instanceof操作符判断一个实例是不是某种类型:
Person p = new Student();
if (p instanceof Student) {
// 只有判断成功才会向下转型:
Student s = (Student) p; // 一定会成功
}
public class Main {
public static void main(String[] args) {
Object obj = "hello";
if (obj instanceof String s) {
// 可以直接使用变量s:
System.out.println(s.toUpperCase());
}
}
}
组合
使用组合,Student
可以持有一个Book
实例:
class Student extends Person {
protected Book book;
protected int score;
}
继承是is关系,组合是has关系。
小结
- 继承是面向对象编程的一种强大的代码复用方式;
- Java只允许单继承,所有类最终的根类是
Object
; protected
允许子类访问父类的字段和方法;- 子类的构造方法可以通过
super()
调用父类的构造方法; - 可以安全地向上转型为更抽象的类型;
- 可以强制向下转型,最好借助
instanceof
判断; - 子类和父类的关系是is,has关系不能用继承。
多态
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法(包括名字、返回数据类型和输入数据类型),被称为覆写(Override)。
例如:
class Person {
public void run() {
System.out.println("Person.run");
}
}
class Student extends Person {
@Override //加上@Override可以让编译器帮助检查是否进行了正确的覆写。希望进行覆写,但是不小心写错了方法签名,编译器会报错。
public void run() {
System.out.println("Student.run");
}
}
**Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。**该情况称为多态。
意义在于,对新增的处理需求,不需要修改代码,只需要从之前的方法中派生正确覆写即可。允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。
一个例题:不同税率下的总收税计算
public class Learn_Polymorphic {
public static void main(String[] args) {
// 给一个有普通收入、工资收入和享受国务院特殊津贴的小伙伴算税:
System.out.println("start");
Income[] incomes = new Income[] {
new Income(3000),
new Salary(7500),
new StateCouncilSpecialAllowance(15000)
};
double tax_all = totalTax(incomes);
System.out.println(tax_all);
}
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方法只和Income打交道,而不需要知道,这个Income是父类Income还是它的某种子类
覆写Object方法
所有的class
最终都继承自Object
,而Object
定义了几个重要的方法,在必要的情况下,我们可以覆写Object
的这几个方法:
toString()
:把instance输出为String
;equals()
:判断两个instance是否逻辑相等;hashCode()
:计算一个instance的哈希值。
通过调用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修饰的方法不能被覆写。
class Person {
protected String name;
public final String hello() {
return "Hello, " + name;
}
}
Student extends Person {
// compile error: 不允许覆写
@Override
public String hello() {
}
}
如果一个类不希望被继承,可以把类本身标记为final。
final class Person {
protected String name;
}
小结
- 子类可以覆写父类的方法(Override),覆写在子类中改变了父类方法的行为;
- Java的方法调用总是作用于运行期对象的实际类型,这种行为称为多态;
final
修饰符有多种作用:final
修饰的方法可以阻止被覆写;final
修饰的class可以阻止被继承;final
修饰的field必须在创建对象时初始化,随后不可修改。
抽象类
由于多态特性的存在和子类覆写的需求,父类中即使某一方法不被用到,依然需要定义。如果父类的方法不需要任何功能,仅用于定义方法签名,目的是让子类覆写,可以把它声明为抽象方法,若方法是抽象的,那么类也要声明为抽象类:
abstract class Person {
public abstract void run();
}
使用abstract
修饰的类就是抽象类。我们无法实例化一个抽象类,只能用于继承,相当于定义了规范,即子类必须覆写抽象方法:
// Person p = new Person(); // 编译错误
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去引用具体的子类实例:
Person s = new Student();
Person t = new Teacher();
这种尽量引用高层类型,避免引用实际子类型的方法,称之为面向抽象编程。
- 上层代码只定义规范(如:abstract class Person)
- 不需要子类就能实现业务逻辑
- 具体的业务逻辑由不同的子类实现,调用者不用关心(直接使用父类即可)
样例:
/*
* @Description : 如果父类方法无需实现功能,仅仅是为了定义签名,目的是让子类去覆写,那么可以把父类的方法声明为抽象方法。
含有抽象方法的类必须定义为抽象类,无法实例化。
抽象类本身被设计成只能继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错,相当于定义了规范。
*/
public class Learn_abstract{
public static void main(String[] args){
// TODO: 用抽象类给一个有工资收入和稿费收入的小伙伴算税:
Income[] incomes = new Income[] {new SalaryIncome(7500), new RoyaltyIncome(12000) };
double total = 0;
// TODO:
for (Income in:incomes)
{
total += in.getTax();
}
System.out.println(total);
}
}
/* 计税的抽象类 */
abstract class Income
{
protected double income;
public Income(double income)
{
this.income = income;
}
public abstract double getTax();
}
/* 工资计税 */
class SalaryIncome extends Income
{
public SalaryIncome(double income)
{
super(income);
}
@Override
public double getTax()
{
if (this.income <= 5000) {
return 0;
}
return (this.income - 5000) * 0.2;
}
}
/** * 稿费计税 */
class RoyaltyIncome extends Income
{
public RoyaltyIncome(double income)
{
super(income);
}
@Override
public double getTax()
{
return this.income * 0.1;
}
}
小结
- 通过
abstract
定义的方法是抽象方法,它只有定义,没有实现。抽象方法定义了子类必须实现的接口规范; - 定义了抽象方法的class必须被定义为抽象类,从抽象类继承的子类必须实现抽象方法;
- 如果不实现抽象方法,则该子类仍是一个抽象类;
- 面向抽象编程使得调用者只关心抽象方法的定义,不关心子类的具体实现。
接口
如果一个抽象类没有字段,所有方法全都是抽象方法,那么可以把抽象类改写成接口 interface(可以有default方法)。默认修饰符都是public abstract,在使用时使用implements:
interface Person {
void run();
String getName();
}
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;
}
}
一个类可以实现多个interface(不同于继承只能有一个):
class Student implements Person, Hello {
// 实现了两个interface
...}
术语
Java的接口特指interface
的定义,表示一个接口类型和一组方法签名,而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。
一个interface可以继承自另一个interface
interface Hello {
void hello();
}
interface Person extends Hello { //接口从接口继承
void run();
String getName();
}
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() { //default方法
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
方法和抽象类的普通方法是有所不同的。因为interface
没有字段,default
方法无法访问字段,而抽象类的普通方法可以访问实例字段。
小结
- Java的接口(interface)定义了纯抽象规范,一个类可以实现多个接口;
- 接口也是数据类型,适用于向上转型和向下转型;
- 接口的所有方法都是抽象方法,接口不能定义实例字段;
- 接口可以定义
default
方法(JDK>=1.8)。
静态字段和静态方法
静态字段
static修饰的字段称为静态字段,每个实例有自己的方空间,所有静态字段只有一个公共共享空间,所有实例都能共享该字段
对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例,所有实例共享一个静态字段。:
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); // 88
hong.number = 99;
System.out.println(ming.number); // 99
}
}
class Person {
public String name;
public int age;
public static int number;
public Person(String name,int age){
this.name = name;
this.age = age;
}
}
不推荐用实例变量.静态字段
去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段
来访问静态对象。可以把静态字段理解为描述class
本身的字段(非实例字段)。
Person.number = 99;
静态方法
调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。
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;
}
}
接口的静态字段
因为interface
是一个纯抽象类,所以它不能定义实例字段。但是,interface
是可以有静态字段的,并且静态字段必须为final
类型(严格来讲只能是public static final
类型):
public interface Person {
public static final int MALE = 1;
public static final int FEMALE = 2;
}
小结
- 静态字段属于所有实例“共享”的字段,实际上是属于
class
的字段; - 调用静态方法不需要实例,无法访问
this
,但可以访问静态字段和其他静态方法; - 静态方法常用于工具类和辅助方法。
包
处理同类名冲突:Java定义了一种名字空间,称之为包:package
。一个类总是属于某个包,类名(比如Person
)只是一个简写,真正的完整类名是包名.类名
。
如:JDK的Arrays
类存放在包java.util
下面,因此,完整类名是java.util.Arrays
。
在定义class时,第一行生命这个class属于哪个包,多层的包名用‘.’隔开:
package ming; // 申明包名ming
public class Person {
}
需要按照包结构把上面的Java文件组织起来。假设以package_sample
作为根目录,src
作为源码目录,所有Java文件对应的目录层次要和包的层次一致。:
编译后的.class
文件也需要按照包结构存放。如果使用IDE,把编译后的.class
文件放到bin
目录下,那么,编译的文件结构就是:
编译的命令相对比较复杂,我们需要在src
目录下执行javac
命令:
javac -d ../bin ming/Person.java hong/Person.java mr/jun/Arrays.java
作用域
同一个包的类,可以访问包作用域的字段和方法。不用public
、protected
、private
修饰的字段和方法就是包作用域。例如Main类和Person类都在hello包下
package hello;
public class Person {
// 包作用域:
void hello() {
System.out.println("Hello!");
}
}
package hello;
public class Main {
public static void main(String[] args) {
Person p = new Person();
p.hello(); // 可以调用,因为Main和Person在同一个包
}
}
import
引用其他包的类:例如,小明的ming.Person
类,如果要引用小军的mr.jun.Arrays
类,他有三种写法:
- 写出完整包名
// Person.java
package ming;
public class Person {
public void run() {
mr.jun.Arrays arrays = new mr.jun.Arrays();
}
}
-
用import语句
import mr.jun.Arrays;
在写
import
的时候,可以使用*
,表示把这个包下面的所有class
都导入进来(但不包括子包的class
)import mr.jun.*;
-
import static
的语法,它可以导入可以导入一个类的静态字段和静态方法(用的少):
import static java.lang.System.*;
Java编译器最终编译出的.class
文件只使用完整类名,因此,在代码中,当编译器遇到一个class
名称时:
- 如果是完整类名,就直接根据完整类名查找这个
class
; - 如果是简单类名,按下面的顺序依次查找:
- 查找当前
package
是否存在这个class
; - 查找
import
的包是否包含这个class
; - 查找
java.lang
包是否包含这个class
。
- 查找当前
如果有两个class
名称相同,例如,mr.jun.Arrays
和java.util.Arrays
,那么只能import
其中一个,另一个必须写完整类名。要注意不要和java.lang
包的类重名,也不要和JDK常用类重名。
小结
- Java内建的
package
机制是为了避免class
命名冲突; - JDK的核心类使用
java.lang
包,编译器会自动导入; - JDK的其它常用类定义在
java.util.*
,java.math.*
,java.text.*
,……; - 包名推荐使用倒置的域名,例如
org.apache
。
作用域
Java内建的访问权限包括public
、protected
、private
和package
权限;
Java在方法内部定义的变量是局部变量,局部变量的作用域从变量声明开始,到一个块结束;
final
修饰符不是访问权限,它可以修饰class
、field
和method
;
一个.java
文件只能包含一个public
类,但可以包含多个非public
类。
内部类
通常情况下,我们把不同的类组织在不同的包下面,对于一个包下面的类来说,它们是在同一层次,没有父子关系。
还有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。Java的内部类分为好几种,通常情况用得不多,但也需要了解它们是如何使用的。
Inner Class
一个类定义在另一个类内部,不能单独存在,必须依附于一个outer Class的实例:
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested"); // 实例化一个Outer,删除则报错
Outer.Inner inner = outer.new Inner(); // 调用Outer实例的new来创建实例化一个Inner
inner.hello();
}
}
class Outer {
private String name;
Outer(String name) { // 构造函数
this.name = name;
}
class Inner {
void hello() {
System.out.println("Hello, " + Outer.this.name);
}
}
}
这是因为Inner Class除了有一个this
指向它自己,还隐含地持有一个Outer Class实例,可以用Outer.this
访问这个实例。所以,实例化一个Inner Class不能脱离Outer实例。
另外Inner实例可以修改Outer Class的private
字段,因为Inner Class的作用域在Outer Class内部,所以能访问Outer Class的private
字段和方法。
Anonymous Class
在方法内部,通过匿名类(Anonymous Class)来定义。匿名类没有用到class关键词来声明定义类名。
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested");
outer.asyncHello();
}
}
class Outer {
private String name;
Outer(String name) {
this.name = name;
}
void asyncHello() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello, " + Outer.this.name);
}
};
new Thread(r).start();
}
}
观察asyncHello()
方法,我们在方法内部实例化了一个Runnable
。Runnable
本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了Runnable
接口的匿名类,并且通过new
实例化该匿名类,然后转型为Runnable
。在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:
Runnable r = new Runnable() {
// 实现必要的抽象方法...
};
除了接口外,匿名类也完全可以继承自普通类。观察以下代码:
import java.util.HashMap;
public class Main {
public static void main(String[] args) {
HashMap<String, String> map1 = new HashMap<>();//普通HashMap实例
HashMap<String, String> map2 = new HashMap<>() {}; // 匿名类实例!继承自HashMap
HashMap<String, String> map3 = new HashMap<>() {
{
put("A", "1");
put("B", "2");
}
};//添加static代码块初始化数据
System.out.println(map3.get("A"));
}
}
Static Nested Class(静态内部类)
public class Main {
public static void main(String[] args) {
Outer.StaticNested sn = new Outer.StaticNested();
sn.hello();
}
}
class Outer {
private static String NAME = "OUTER";
private String name;
Outer(String name) {
this.name = name;
}
static class StaticNested {
void hello() {
System.out.println("Hello, " + Outer.NAME);
}
}
}
完全独立,不依附于Outer的实例。无法引用Outer.this,可以访问Outer的静态字段和静态方法。
小结
- Inner Class和Anonymous Class本质上是相同的,都必须依附于Outer Class的实例,即隐含地持有
Outer.this
实例,并拥有Outer Class的private
访问权限; - Static Nested Class是独立类,但拥有Outer Class的
private
访问权限。
classpath和jar
classpath是JVM用到的一个环境变量,用来指示JVM搜索class
建议在启动JVM时传入classpath:
java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello
java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello
jar:
jar包可以把package
组织的目录层级,以及各个目录下的所有文件(包括.class
文件和其他文件)都打成一个jar文件,便于备份或者交付。
jar包还可以包含一个特殊的/META-INF/MANIFEST.MF
文件,MANIFEST.MF
是纯文本,可以指定Main-Class
和其它信息。JVM会自动读取这个MANIFEST.MF
文件,如果存在Main-Class
,我们就不必在命令行指定启动的类名,而是用更方便的命令:java -jar hello.jar
如果要执行jar包的class,就可以把jar包放到classpath中:
java -cp ./hello.jar abc.xyz.Hello
通过压缩文件压成zip再自行改后缀即可。
可以用Maven创建jar包
小结
- JVM通过环境变量
classpath
决定搜索class
的路径和顺序; - 不推荐设置系统环境变量
classpath
,始终建议通过-cp
命令传入; - jar包相当于目录,可以包含很多
.class
文件,方便下载和使用; MANIFEST.MF
文件可以提供jar包的信息,如Main-Class
,这样可以直接运行jar包。
模块
jar只是用于存放class的容器,它并不关心class之间的依赖,引入模块主要是为了解决“依赖”这个问题。
如果a.jar
必须依赖另一个b.jar
才能运行,那我们应该给a.jar
加点说明啥的,让程序在编译和运行的时候能自动定位到b.jar
,这种自带“依赖关系”的class容器就是模块。
把一堆class封装为jar仅仅是一个打包的过程,而把一堆class封装为模块则不但需要打包,还需要写入依赖关系,并且还可以包含二进制代码(通常是JNI扩展)。此外,模块支持多版本,即在同一个模块中可以为不同的JVM提供不同的版本。模块也进一步隔离了代码的访问权限
编写模块
bin
目录存放编译后的class文件,src
目录存放源码,按包名的目录结构存放,仅仅在src
目录下多了一个module-info.java
这个文件,这就是模块的描述文件。在这个模块中,它长这样:
module hello.world { //module是关键字,后面是模块名
requires java.base; // 可不写,任何模块都会自动引入java.base
requires java.xml;
}
当我们使用模块声明了依赖关系后,才能使用引入的模块。例如,Main.java
代码如下:
package com.itranswarp.sample;
// 必须引入java.xml模块后才能使用其中的类:
import javax.xml.XMLConstants;
public class Main {
public static void main(String[] args) {
Greeting g = new Greeting();
System.out.println(g.hello(XMLConstants.XML_NS_PREFIX));
}
}
创建方法:
切换到模块目录,编译所有.java并存放到bin目录:
$ javac -d bin src/module-info.java src/com/itranswarp/sample/*.java
然后将bin中的class打包成jar,注意传入–main-class参数,让jar能自己定位main方法所在的类。在将jar转为模块。
$ jar --create --file hello.jar --main-class com.itranswarp.sample.Main -C bin .
$ jmod create --class-path hello.jar hello.jmod
运行模块
$ java --module-path hello.jar --module hello.world
Hello,xml!
jmod不能放在–module-path中换成.jar。jmod用于打包JRE
$ jlink --module-path hello.jmod --add-modules java.base,java.xml,hello.world --output jre/
我们在--module-path
参数指定了我们自己的模块hello.jmod
,然后,在--add-modules
参数中指定了我们用到的3个模块java.base
、java.xml
和hello.world
,用,
分隔。最后,在--output
参数指定输出目录。
现在,在当前目录下,我们可以找到jre
目录,这是一个完整的并且带有我们自己hello.jmod
模块的JRE。试试直接运行这个JRE:
$ jre/bin/java --module hello.world
Hello, xml!
要分发我们自己的Java应用程序,只需要把这个jre
目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署。
访问时,要访问不同模块之间的,只能访问导入的包:
举个例子:我们编写的模块hello.world
用到了模块java.xml
的一个类javax.xml.XMLConstants
,我们之所以能直接使用这个类,是因为模块java.xml
的module-info.java
中声明了若干导出:
module java.xml {
exports java.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;
...
}
只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的hello.world
模块中的com.itranswarp.sample.Greeting
类,我们必须将其导出:
module hello.world {
exports com.itranswarp.sample;
requires java.base;
requires java.xml;
}
小结
- Java 9引入的模块目的是为了管理依赖;
- 使用模块可以按需打包JRE;
- 使用模块对类的访问权限有了进一步限制。
Java核心类
方法的包前缀用String,如果有String s,则可以用s做前缀
字符串
String本身也是一个class(实际上内部是char[])
字符串相关方法
判断 | |
---|---|
s.equals() | 比较是否相等 |
s.equalsIgnoreCase() | 忽略大小写比较相等 |
s.isEmpty() | 判定是否为空字符串 |
s.isBlank() | 判定是否包含空白字符 |
检索、提取 | |
---|---|
s.contains("") | s中是否包含字符串 |
s.indexOf("") | s中字符的第一个位置 |
s.lastIndexOf("") | s中字符的最后一个位置 |
s.startsWith("") | 检查字符串是否以指定子字符串开头 |
s.endsWith("") | 检查字符串是否以指定子字符串结尾 |
s.substring(int,int) | 提取索引号范围的子字符串 |
处理 | |
---|---|
s.toUpperCase() | 转为大写 |
s.toLowerCase() | 转为小写 |
s.trim() | 去除字符串首尾空白字符,包括\t, \r, \n。返回一个新字符串 |
s.strip() | 功能同上,增加去除\u3000 |
s.stripLeading() | 去除前空白字符 |
s.stripTrailing() | 去除后空白字符 |
s.replace(str,str) | 用前替换后 |
s.replaceAll() | s.replaceAll("[\,\;\s]+", “,”); // “A,B,C,D” |
s.split(" ") | 用字符串分割s |
s.valueOf() | 重载,转为字符串 |
Integer.parseInt(数字) | 字符串转int。int n1 = Integer.parseInt(“123”); // 123 |
Boolean.parseBoolean() | 字符串转boolean类型 |
getInteger(String) | 把字符串对应变量转换为Integer |
Array.copyOf(目标array,复制长度) | 复制目标列表中的固定长度 |
s.getBytes() | 转换为某一类型,如UTF-8或GBK,转为byte类型数组 |
拼接字符串:s.join( , )
String[] arr = {"A", "B", "C"};
String s = String.join("***", arr); // "A***B***C"
String s3 = String.join("*","A","B","C"); //"A*B*C"
格式化字符串:s.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)); //有几个占位符,后面就传入几个参数。参数类型要和占位符一致。
}
}
%s
:显示字符串;%d
:显示整数;%x
:显示十六进制整数;%f
:显示浮点数。
s.toCharArray() | String -> char[] |
---|---|
new String(char[]) | char[] -> String;赋值之后直接修改原[]不会改到String,是复制不是引用 |
小结
- Java字符串
String
是不可变对象; - 字符串操作不改变原字符串内容,而是返回新字符串;
- 常用的字符串操作:提取子串、查找、替换、大小写转换等;
- Java使用Unicode编码表示
String
和char
; - 转换编码就是将
String
和byte[]
转换,需要指定编码; - 转换为
byte[]
时,始终优先考虑UTF-8
编码。
StringBuilder
为了高效拼接字符串,避免不断扔掉旧字符串创建新字符串,提供了StringBuilder库,可以预分配缓存区,往StringBuilder新增字符时,不会创建新的临时对象。
StringBuilder sb = new StringBuilder(1024);
for (int i = 0; i < 1000; i++) {
sb.append(',');
sb.append(i);
}
String s = sb.toString();
可以链式操作:(因为原函数内不断调用this)
sb.append("Mr ")
.append("Bob")
.append("!");
同理设计计数器:
public class Main {
public static void main(String[] args) {
Adder adder = new Adder();
adder.add(3)
.add(5)
.inc()
.add(10);
System.out.println(adder.value());
}
}
class Adder {
private int sum = 0;
public Adder add(int n) {
sum += n;
return this;
}
public Adder inc() {
sum ++;
return this;
}
public int value() {
return sum;
}
}
小结
StringBuilder
是可变对象,用来高效拼接字符串;StringBuilder
可以支持链式操作,实现链式操作的关键是返回实例本身;StringBuffer
是StringBuilder
的线程安全版本,现在很少使用。
StringJoiner
类似用分隔符拼接数组
import java.util.StringJoiner;
var sj = new StringJoiner(", ", "Hello ", "!"); // 连接符,开头,结尾
import java.util.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());
}
}
小结
- 用指定分隔符拼接字符串数组时,使用
StringJoiner
或者String.join()
更方便; - 用
StringJoiner
拼接字符串时,还可以额外附加一个“开头”和“结尾”。
包装类型
- 基本类型:
byte
,short
,int
,long
,boolean
,float
,double
,char
- 引用类型:所有
class
和interface
类型
把基本类型视作引用类型:如int,可以定义一个Integer
类,它只包含一个实例字段int
,这样,Integer
类就可以视为int
的包装类
Integer n = null;
Integer n2 = new Integer(99);
int n3 = n2.intValue();
public class Main {
public static void main(String[] args) {
int i = 100;
// 通过new操作符创建Integer实例(不推荐使用,会有编译警告):
Integer n1 = new Integer(i);
// 通过静态方法valueOf(int)创建Integer实例:
Integer n2 = Integer.valueOf(i);
// 通过静态方法valueOf(String)创建Integer实例:
Integer n3 = Integer.valueOf("100");
System.out.println(n3.intValue());
}
}
基本类型 | 对应的引用类型 |
---|---|
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 |
Auto Boxing
Integer n = 100; // 编译器自动Integer.valueOf(int)
int x = n; // 编译器自动Integer.intValue()
int—>Integer 自动装箱
Integer—>int 自动拆箱
不变类
所有包装类型都是不变类,即用final修饰。不能用==比较,要用equal();
因为Integer.valueOf()
可能始终返回同一个Integer
实例,因此,在我们自己创建Integer
的时候,以下两种方法,方法2更好,因为方法1总是创建新的Integer
实例,方法2把内部优化留给Integer
的实现者去做
- 方法1:
Integer n = new Integer(100);
- 方法2:
Integer n = Integer.valueOf(100);
把能创建“新”对象的静态方法称为静态工厂方法。Integer.valueOf()
就是静态工厂方法,它尽可能地返回缓存的实例以节省内存。
进制转换
Integer.parseInt("100") //把字符串解析成整数
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进制
}
所有的整数和浮点数的包装类型都继承自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();
在Java中,并没有无符号整型(Unsigned)的基本数据类型。byte
、short
、int
和long
都是带符号整型,最高位是符号位。
小结
- Java核心库提供的包装类型可以把基本类型包装为
class
; - 自动装箱和自动拆箱都是在编译期完成的(JDK>=1.5);
- 装箱和拆箱会影响执行效率,且拆箱时可能发生
NullPointerException
; - 包装类型的比较必须使用
equals()
; - 整数和浮点数的包装类型都继承自
Number
; - 包装类型提供了大量实用方法。
JavaBean
class 定义符合的规范:
- 若干
private
实例字段; - 通过
public
方法来读写实例字段。
如果读写方法符合以下这种命名规范:那么这种class
被称为JavaBean
:
// 读方法:
public Type getXyz()
// 写方法:
public void setXyz(Type value)
我们通常把一组对应的读方法(getter
)和写方法(setter
)称为属性(property
)。例如,name
属性:
- 对应的读方法是
String getName()
- 对应的写方法是
setName(String)
只有getter
的属性称为只读属性(read-only),例如,定义一个age只读属性,只有setter
的属性称为只写属性(write-only)。
要枚举一个JavaBean的所有属性,可以直接使用Java核心库提供的Introspector
:
import java.beans.*;
public class Main {
public static void main(String[] args) throws Exception {
BeanInfo info = Introspector.getBeanInfo(Person.class);
for (PropertyDescriptor pd : info.getPropertyDescriptors()) {
System.out.println(pd.getName());
System.out.println(" " + pd.getReadMethod());
System.out.println(" " + pd.getWriteMethod());
}
}
}
class Person {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
运行上述代码,可以列出所有的属性,以及对应的读写方法。注意class
属性是从Object
继承的getClass()
方法带来的。
小结
- JavaBean是一种符合命名规范的
class
,它通过getter
和setter
来定义属性; - 属性是一种通用的叫法,并非Java语法规定;
- 可以利用IDE快速生成
getter
和setter
; - 使用
Introspector.getBeanInfo()
可以获取属性列表。
枚举类
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 { //用enum关键词来替代class
SUN, MON, TUE, WED, THU, FRI, SAT;//枚举的内容
}
enum 常量本身带有类型信息,即Weekday.SUN
类型是Weekday
,编译器会自动检查出类型错误,其次,不可能引用到非枚举的值,因为无法通过编译。最后,不同类型的枚举不能互相比较或者赋值,因为类型不符。例如,下面的语句不可能编译通过:
int day = 1;
if (day == Weekday.SUN) { // Compile error: bad operand types for binary operator '=='
}
因为enum
类型的每个常量在JVM中只有一个唯一实例,所以可以直接用==
比较。
只能定义出enum
的实例,而无法通过new
操作符创建enum
的实例;可以将enum
类型用于switch
语句。
方法 | 作用 | 举例 |
---|---|---|
name() | 返回常量的名字 | String s = Weekday.SUN.name(); // “SUN” |
ordinal() | 返回定义的常量的顺序 | int n = Weekday.MON.ordinal(); // 1 |
但是,如果不小心修改了枚举的顺序,编译器是无法检查出这种逻辑错误的。要编写健壮的代码,就不要依靠ordinal()
的返回值。因为enum
本身是class
,所以我们可以定义private
的构造方法,并且,给每个枚举常量添加字段:
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;
}
对枚举常量调用toString()
会返回和name()
一样的字符串。但是,toString()
可以被覆写,而name()
则不行。我们可以给Weekday
添加toString()
方法:
public String toString() {
return this.chinese;
}
加上default
语句,可以在漏写某个枚举常量时自动报错,从而及时发现错误。
小结
- Java使用
enum
定义枚举类型,它被编译器编译为final class Xxx extends Enum { … }
; - 通过
name()
获取常量定义的字符串,注意不要使用toString()
; - 通过
ordinal()
返回常量定义的顺序(无实质意义); - 可以为
enum
编写构造方法、字段和方法 enum
的构造方法要声明为private
,字段强烈建议声明为final
;enum
适合用在switch
语句中。
记录类
不变类特点:
- 定义class时使用
final
,无法派生子类; - 每个字段使用
final
,保证创建实例后无法修改任何字段。
假设希望定义一个类,有x、y两个变量,同时是一个不变类:
public final class Point {
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;
}
}
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) {}
//上述record语句等同于以下代码
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()
方法。
和enum
类似,我们自己不能直接从Record
派生,只能通过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);
小结
- 使用
record
定义的是不变类; - 可以编写Compact Constructor对参数进行验证;
- 可以定义静态方法。
BigInteger
用来模拟超出long型的整数
小结
BigInteger
用于表示任意大小的整数;BigInteger
是不变类,并且继承自Number
;- 将
BigInteger
转换成基本类型时可使用longValueExact()
等方法保证结果准确。
BigDecimal
BigDecimal
可以表示一个任意大小且精度完全准确的浮点数
小结
BigDecimal
用于表示精确的小数,常用于财务计算;- 比较
BigDecimal
的值是否相等,必须使用compareTo()
而不能使用equals()
。
常用工具类
Math
用于数学计算
方法 | 作用 |
---|---|
Math.abs() | 绝对值 |
Math.max() | |
Math.pow(n,m) | n的m次方 |
Math.sqrt() | 开方 |
Math.exp() | ex |
Math.random() | 随机数 |
Random r = new Random(); //伪随机
r.nextInt() | 每次都不一样 |
---|---|
r.nextInt(10) | 生成一个[0,10)之间的int |
r.nextLong() | |
r.nextFloat() | |
r.nextDouble() |
SecureRandom
SecureRandom
无法指定种子,它使用RNG(random number generator)算法。JDK的SecureRandom
实际上有多种不同的底层实现,有的使用安全随机种子加上伪随机数算法来产生安全的随机数,有的使用真正的随机数生成器。实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器:
时刻牢记必须使用SecureRandom
来产生安全的随机数。
小结
- Math:数学计算
- Random:生成伪随机数
- SecureRandom:生成安全的随机数