数组
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));
}
}
常用的排序算法有冒泡排序、插入排序和快速排序等。
Java的标准库内置了排序功能,我们只需要调用JDK提供的Arrays.sort()
就可以排序。
必须注意,对数组排序实际上修改了数组本身。
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
数组的每个元素指向变化了。
二维数组
二维数组就是数组的数组。
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
}
}
因为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 }
。在内存中,结构如下:使用Java标准库的Arrays.deepToString()
:
arr0 ─────┐
▼
┌───┬───┬───┬───┐
┌───┐ ┌──▶│ 1 │ 2 │ 3 │ 4 │
ns ─────▶│░░░│──┘ └───┴───┴───┴───┘
├───┤ ┌───┬───┬───┬───┐
│░░░│─────▶│ 5 │ 6 │ 7 │ 8 │
├───┤ └───┴───┴───┴───┘
│░░░│──┐ ┌───┬───┬───┬───┐
└───┘ └──▶│ 9 │10 │11 │12 │
└───┴───┴───┴───┘
使用Java标准库的Arrays.deepToString()打印二维数组。
三维数组
三维数组就是二维数组的数组。
int[][][] ns = {
{
{1, 2, 3},
{4, 5, 6},
{7, 8, 9}
},
{
{10, 11},
{12, 13}
},
{
{14, 15, 16},
{17, 18}
}
};
命令行参数
1、命令行参数类型是String[]
数组;
2、命令行参数由JVM接收用户输入并传给main
方法;
3、如何解析命令行参数需要由程序自己实现。
面向对象编程
Java是一种面向对象的编程语言。面向对象编程,英文是Object-Oriented Programming,简称OOP。
那什么是面向对象编程?
和面向对象编程不同的,是面向过程编程。面向过程编程,是把模型分解成一步一步的过程。比如,老板告诉你,要编写一个TODO任务,必须按照以下步骤一步一步来:
- 读取文件;
- 编写TODO;
- 保存文件。
而面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
面向对象的基本概念,包括:
-
类
-
实例
-
方法
面向对象的实现方式,包括:
-
继承
-
多态
Java语言本身提供的机制,包括:
-
package
-
classpath
-
jar
以及Java标准库提供的核心类,包括:
-
字符串
-
包装类型
-
JavaBean
-
枚举
-
常用工具类
注:面向对象和面向过程是两种不同的编程思想和方法。面向过程编程是指按照步骤顺序进行编程,将程序分解为若干个函数或模块,以实现特定的功能。它强调每一步的细节和顺序,是一种较为直观的程序设计方法。面向对象编程则是以对象为中心进行编程,将程序设计的重点放在数据结构、对象之间的互动以及方法上,通过将功能进行封装,以简化程序的设计和开发过程。它强调对象的属性和方法,将程序看作是一些对象的集合。总的来说,面向过程是以“做什么”为核心,面向对象是以“谁来做”的角度考虑问题,两种方式各有优缺点,具体选择视问题而定。
创建实例
定义class
就是定义了一种数据类型,对应的instance
是这种数据类型的实例;
定义了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
字段,且各自都有一份独立的数据,互不干扰。
注:一个Java源文件可以包含多个类的定义,但只能定义一个public类,且public类名必须与文件名一致。如果要定义多个public类,必须拆到多个Java源文件中。
this变量
在方法内部,可以使用一个隐含的变量this
,它始终指向当前实例。
但是,如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上this
:
class Person {
private String name;
public void setName(String name) {
this.name = name; // 前面的this不可少,少了就变成局部变量name了
}
}
可变参数
可变参数用类型...
定义,可变参数相当于数组类型:
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
参数绑定
调用方把参数传递给实例方法时,调用时传递的值会按参数位置一一绑定。
1、基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。
2、引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛)。
一个基本类型参数的传递:
// 基本类型参数绑定
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;
}
}
一个传递引用参数的例子:
// 引用类型参数绑定
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;
}
}
又一个引用类型例子:
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;
}
}
试解释为什么上面的代码两次输出都是"Bob"
。
在 Java 中,String 和 String[] 都是引用类型,存储的是对象的引用。但是,String 类型的对象是不可变的
,也就是说,一旦创建了一个 String 对象,就无法再修改它的值,任何对 String 对象的修改都会创建一个新的对象。而 String[] 类型的对象是可变的,也就是说,可以修改数组中的元素值。
当我们将一个 String[] 类型的变量传递给一个方法时,实际上传递的是该变量所引用的数组对象在内存中的地址。因此,当我们在方法中修改数组中的元素值时,对数组进行的修改会影响到原始数组,在方法返回后也能保留。而 String 类型因为不可变,修改即创建新的对象也就有了新的地址,所以方法中引用的地址的值没有改变,输出的结果也就没有改变。
(疑惑:string类型也是存储的地址,当修改时,变量就指向新创建的地址,这个地址的改变不会影响参数的传递吗?)
在给string重新赋值的时候,其实是创造了一块新的内存,并且把string的指针指向了这块内存。所以原本的this.name到原本的名字的路径不受影响(this.name【引用类型】仍然指向原来的名字)。但是在给数组重新赋值的时候,我们是直接在原本的内存上改的,这时候this.name仍然指向这一块内存,所以结果会变。
构造方法
构造方法可以在创建对象实例时就把内部字段全部初始化为合适的值。
如果一个类没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数,也没有执行语句。
特别注意的是,如果我们自定义了一个构造方法,那么,编译器就不再自动创建默认构造方法。
没有在构造方法中初始化字段时,引用类型的字段默认是null
,数值类型的字段用默认值,int
类型默认值是0
,布尔类型默认值是false。
那么问题来了:既对字段进行初始化,又在构造方法中对字段进行初始化:
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)
的字段值最终由构造方法的代码确定。
一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是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)
}
}
如果我们手动创建了一个构造方法,编译器就会认为我们已经指定了类的初始化方式,因此默认构造方法就不再需要了。此外,手动创建构造方法也可以更好地控制类的实例化过程,以便更好地满足我们的需求。
方法重载
在一个类中,我们可以定义多个方法。如果有一系列方法,它们的功能都是类似的,只有参数有所不同,那么,可以把这一组方法名做成同名方法。
注意:方法重载的返回值类型通常都是相同的。
方法重载的目的是,功能类似的方法使用同一名字,更容易记住,因此,调用起来更简单。
继承
继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。
注意:子类自动获得了父类的所有字段,严禁定义与父类重名的字段!
在OOP的术语中,我们把Person
称为超类(super class),父类(parent class),基类(base class),把Student
称为子类(subclass),扩展类(extended class)。
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
字段和方法可以被其子类,以及子类的子类所访问。
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()
并给出参数以便让编译器定位到父类的一个合适的构造方法。
此外,子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。
阻止继承
正常情况下,只要某个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
。
向上转型
如果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
。
向下转型
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)。但是只针对父类变量指向子类实例的情况。纯粹的父类实例是没有办法向下转型的,因为他不具有子类的部分功能。
为了避免向下转型出错,Java提供了instanceof
操作符,可以先判断一个实例究竟是不是某种类型:
Person p = new Person();
System.out.println(p instanceof Person); // true
System.out.println(p instanceof Student); // false
instanceof
实际上判断一个变量所指向的实例是否是指定类型,或者这个类型的子类。如果一个引用变量为null
,那么对任何instanceof
的判断都为false
。
从Java 14开始,判断instanceof
后,可以直接转型为指定变量,避免再次强制转型。例如,对于以下代码:
Object obj = "hello";
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.toUpperCase());
}
可以改写如下:
public class Main {
public static void main(String[] args) {
Object obj = "hello";
if (obj instanceof String s) {
// 可以直接使用变量s:
System.out.println(s.toUpperCase());
}
}
}
继承是is关系,组合是has关系。
向上转型是将子类的实例赋值给父类的引用变量,通过父类引用来访问子类对象。在向上转型后,只能调用父类中定义的方法和属性,而无法直接访问子类特定的方法和属性。
向下转型是将父类的引用变量重新指向子类对象,并将其类型更改为子类类型。通过向下转型,我们可以使用子类的引用来调用子类特定的方法和属性。
多态
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。
Override和Overload不同的是,如果方法签名不同,就是Overload,Overload方法是一个新方法;如果方法签名相同,并且返回值也相同,就是Override
。
注意:方法名相同,方法参数相同,但方法返回值不同,也是不同的方法。在Java程序中,出现这种情况,编译器会报错。
多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法
所以,多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。
覆写Object方法
因为所有的class
最终都继承自Object
,而Object
定义了几个重要的方法:
toString()
:把instance输出为String
;equals()
:判断两个instance是否逻辑相等;hashCode()
:计算一个instance的哈希值。
调用super
在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过super
来调用。例如:
class Person {
protected String name;
public String hello() {
return "Hello, " + name;
}
}
class 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;
}
}
class Student extends Person {
// compile error: 不允许覆写
@Override
public String hello() {
}
}
如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为final
。用final
修饰的类不能被继承:
final class Person {
protected String name;
}
// compile error: 不允许继承自Person
class 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
字段就不可修改。
参考
https://www.liaoxuefeng.com/wiki/1252599548343744/1255943520012800