Java Programming Review 02

Java Review 02

数字与静态

静态方法

首先我们需要明确为什么需要静态,静态和非静态之间有什么区别。Java 虽是面向对象的编程,但很多时候,我们需要的是常用的方法,而不是类的实例。static 这个修饰符可以标记出不需要实例的方法。所以,一个静态的方法指的就是 “一种不依靠实例变量即不需要对象的行为”。

在这里插入图片描述

根据上方的对比,我们可以轻易地看出来静态方法与非静态方法在取用上方面的差异:

静态方法:以类的名字调用静态的方法

非静态方法:以引用变量的名称来调用非静态的方法

至此,我们可以做出一个结论:带有非静态方法的类一般不期待被初始化,任何非静态的方法必须以某种实例来操作。

  • 静态的方法不能调用非静态的变量:静态的方法无法引用该类的任何实例变量。此时,静态的方法根本不知道堆上有哪些实例
  • 静态的方法不能调用非静态的方法:所谓的非静态的方法一般指那些会被实例变量的状态影响的方法。这些方法在非静态方法中不能被调用

静态变量

静态变量的值对所有实例来说都一样。换言之,静态变量是被同类的所有实例共享的值。

在这里插入图片描述

可以看到,同一类的所有实例共用一个静态变量!!

实例变量:每个实例有一个

静态变量:所有实例共用一个(每个类一个)

静态项目有两项保证:

  1. 静态变量会在该类的任何实例被创建之前就完成初始化
  2. 静态变量会在该类的任何静态方法执行前就完成初始化

静态的 final 变量是常数,即一个被标记为 final 的静态变量在初始化之后就不会被改变,也就是说,类加载之后,静态 final 变量就会保持原值。

在这里插入图片描述

而静态 final 变量的初始化有 2 种方式:显式初始化 / 静态初始化程序(这里需要格外注意,静态初始化程序会在构造函数之前执行

在这里插入图片描述

final 修饰符也能用来标识非静态变量,比如局部变量、实例变量和参数。不管标识哪一个 final 的意思永远都是永恒不变。

final 变量表示你不能改变它的值

final 方法表示你不能覆盖该方法

final 的类表示你不能继承该类(不能创建它的子类)

在这里插入图片描述

数字

Math 的常用方法

在这里插入图片描述

primitive 主数据类型的包装

有时需要将 primitive 当作对象来进行操作。这在 Java 5.0 之前甚至是一种常态。每个 primitive 主数据类型都有个包装用的类,每个包装类的名称基本就是将所包装类型的名字首字母换为大写

在这里插入图片描述

从 Java 5.0 开始加入了 autoboxing 功能。它可以自动将 primitive 主数据类型转换为包装过的对象

在这里插入图片描述

包装也有静态的使用方法,比如最常用的 Integer.parseInt()(接收 string 返回 int)。现在来看一看 String 类的静态方法,String.format()这个静态方法可以将数字进行比较漂亮的格式化

在这里插入图片描述

可以看到,格式化由两个部分组成:格式指令和要格式化的值。 比如上面例子中的 String.format("%, d", 1000000000) 它的意思是使用此方法,将第二个参数以第一个参数指定的带有逗号的整数形式打印。和 C 语言一样,% 符号用来指定第二个参数放置的位置,用 d, f, x, c 分别表示整数、浮点数等。这里我们看一看日期的打印,在 Java 中,Date 类型被用来表示日期。与数字的格式化不同,日期格式的类型使用 ‘t’ 开头的两个字符表示

在这里插入图片描述

而对于日期的操作,只需要记住一句话:“要取得当前的日期时间就用 Date,其余的功能都可以在 Calendar 上找”。为了使用 Calendar,首先需要使用一个静态的方法获得这个抽象类的实例:

在这里插入图片描述

运用 Calendar 对象的时候,需要注意以下几个概念:

  1. 字段会保存状态:Calendar 对象会使用许多字段来保存日期和时间,因为我们可以设置和提取 year 和 month 等字段
  2. 日期和时间可以进行运算
  3. 日期与时间可以用微妙 (millisecond) 表示。准确的来说是自 1970 年 1 月 1 日开始的微秒

下面给出使用 Calendar 的范例:

在这里插入图片描述

下面给出 Calendar API 的一些基本操作:

在这里插入图片描述

*静态的 import

在这里插入图片描述

异常处理

调用别人写的类是一件具有风险的事。Java 会使用异常处理机制 (exception-handling) 处理执行期间的例外情况,同时它也能让处理异常状况的代码处在一个比较易读的位置。只要某个方法的声明包括 throw,那么就表明它会抛出异常。在进行代码编写时,需要将有风险的错误代码以及应对方式放在 try/catch 块中

在这里插入图片描述

各种异常实际上是 Exception 类型的对象。结合我们之前介绍过的多态,我们可以声明 catch 的参数是 Exception 类的 ex 引用变量:

在这里插入图片描述

当我们的程序调用有风险的方法,即声明了有异常的方法时,就是该方法将异常抛给了我们。

在这里插入图片描述

对于不由编译器检查的 RuntimeException 子类,被称为检查异常。我们需要格外注意:

  1. 如果我们要抛出异常,那么一定要用 throw 进行声明
  2. 如果我们调用会抛出异常的方法,必须得确认知道异常的可能性。把调用包放在 try/catch 块是一种常用的方式

在这里插入图片描述

try/catch 的流程控制

try/catch 的流程实际上和 Python 的 try/except 基本一致。首先执行 try 内的行为,如果能成功,就接着运行之后的代码,如果不能成功则执行 except 中的错误处理。而在最后可以添加一个 finally 块,这个块中的代码是无论如何都要执行的内容。

在这里插入图片描述

如果有必要,方法可以抛出多种异常,此时需要对全部的异常进行处理:

在这里插入图片描述

我们需要记住,异常除了可以被抛出之外,和普通的对象并无区别。因此我们也可以对异常使用多态。如此一来,就可以在声明时,声明父类的异常。此时,对于 catch 块我们有两种选择:

  1. 把异常处理代码写成只有一个 catch 块,以父类的 Exception 来捕获
  2. 为每个需要单独处理的异常编写不同的 catch 块。此时需要将每个 catch 从小到达排布,这里的 ”大小“ 实质上体现的就是类在继承树上的位置,在继承树上位置越高,那就越 ”大“;越低,则越 ”小“

在这里插入图片描述

在我们不想要处理异常时,可以使用 duck 来进行回避,简单来说就是调用方也声明同样的 throws Exception。 此时会出现这样一种现象:方法抛出异常时,会被从栈 (stack) 上立刻弹出,异常会被再度丢给栈上的方法。如此一直将皮球踢给下一个栈上的方法。但是这样的错误总要有人接手处理,一般来说,栈上的最后一个方法就是主函数 main(),如果 main() 也 Duck 了,会怎样?

在这里插入图片描述

所以我们应该清楚,处理异常就和我们在日常生活中处理棘手为题时一样:

  1. 直面困难:用 try/catch 提出解决方案
  2. 回避困难:踢给下一个方法

在这里插入图片描述

用户图形接口

为了创建更友好的用户图形界面,可以借助 JFrame这个类,它代表屏幕上窗口的对象,我们可以在这个窗口中任意添加 button, checkbox, text 字段等接口。

基本的操作步骤就是:

  1. 首先创建一个 frame
  2. 创建自定义的各种组件 (widget)
  3. 将组件放入 frame

下面给出一个简单的例子,这个例子中,我们仅在 window 中放置了一个 button

在这里插入图片描述

这里我们需要先关注一下 button 这个组件,当前的 button 并没有任何的功能,但事实上它可以被点击。为了让它在被按下时执行某些特定的任务,需要以下两项:

  1. 被按下时要执行的方法(button 的任务)
  2. 检测 button 被按下的方法

如果想要知道 button 的事件,就需要监听事件的接口。事件源 (这里就是 button) 会在用户做出相关动作时,产生事件对象。每个相应的事件类型都有对应的监听者接口。

以鼠标 (Mouse) 的监听接口 MouseListener 为例,因为鼠标 (Mouse) 的事件有多种类型,因此事件会有:

mousePressed

mouseReleased

mouseMoved 等

接下来就要取得之前创建的 button 的 ActionEvent

在这里插入图片描述

现在再来梳理一下事件 (Event),事件源 (Event Source) 和监听 (Listener) 之间的关系:使用监听器来监听事件源上发生的事件,对应该事件定义处理的方法。

在这里插入图片描述

当我们同时有多个 button 在 frame 中,并且每个 button 有各自的任务需要处理时,可以使用内部类 (internal class) 来处理。内部类指的是嵌套在另一个类内部的类,内部类可以把外部的方法和变量当作自己的:

在这里插入图片描述

内部类的实例一定会绑在外部类的实例上。比如:某个方法的程序代码会初始化内部类,则内部类的对象会绑在执行该方法的实例上。

序列化和文件的输入/输出

对象可以被序列化也可以被展开。我们已经知道,对象有 “状态” 和 “行为” 两种属性。“行为” 存于类中,而 “状态” 存于个别对象中。我们需要根据如何使用存储下来的状态来决定怎样进行存储:

  1. 如果只有自己编写的 Java 程序会使用这些数据:使用序列化 (serialization)。 将被序列化的对象写入文件中,在使用时只需读取这些序列化的对象并展开即可
  2. 如果数据需要被其他程序引用:写一个纯文本文件。 用其他程序可解析的特殊字符写到文件中

存储状态

我们设想一种简单的情景:设计一款闯关游戏。我们肯定希望角色会不断积累经验和装备,因此就需要一种方法保存人物的状态。我们这里假设人物会有 “矮人”,“精灵”, “魔法师” 这三种。

在这里插入图片描述

根据之前的描述,我们有两种选择:

  1. 把 3 种序列化的人物对象写入文件中
  2. 写入纯文本文件。比如创建一个空文档,写入三行文字,以逗号分隔属性

这里我们需要留意,序列化写入的文件以纯文本形式阅读是没有意义的,但是它比纯文本文件更容易让这三种人物的状态得以恢复,同时安全性更好。

Serialization & Deserialization

这里我们需要了解串流 (stream) 的概念。Java 的输入和输出 API 带有连接类型的串流,用以表示目的地和来源之间的连接,连接串流将串流与其他串流连接在一起。一般来说,串流要两两连接才能做出有意义的事,其中一个表示连接,另一个表示要被调用的方法。我们这里直接用序列化对象写入文件来理解:

在这里插入图片描述

可以看到,这里我们一共使用了 2 个串流 (stream):ObjectOutputStreamFileOutputStream

  • ObjectOutputStream:把对象转换为可以写入串流的数据
  • FileOutputStream:将字节写入文件

因此,整个序列化写入的过程可以理解为:调用 ObjectOutputStreamwriteObject将对象打成串流送到 FileOutputStream 来写入文件。

深入地看序列化时发生了什么:

一开始,对象都在堆 (heap) 上,不同的实例有不同的 “状态” (实例变量的值),这赋予了同一类对象不同的存在意义。序列化的对象保留了实例变量的值,因此之后可以在堆上带回一模一样的实例

在这里插入图片描述

这时就需要考虑一种常见的情况,如果某个对象的实例变量引用到了其他对象,该怎么办?

在这里插入图片描述

答案很简单:当对象被序列化时,被该对象引用的所有对象也都会被序列化

在这里插入图片描述

由于序列化是全有或全无的,因此即使是上图种中这样一个复杂的 Kennel 对象版图,序列化时会将这个版图完全序列化。在进行序列化时,需要实现 Serializable,这是一个标记类接口,没有任何的方法,存在的意义只是为了声明有实现它的类都可以被序列化。因此,我们在设计类的时候,如果希望它能被序列化,那么就一定要 implements Serializable 对于无法或者不应该被序列化的实例变量(比如动态数据),可以使用 transient 进行标记,这样在序列化时,就会自动跳过这个变量。此时该引用变量会以 null 返回,表示整个对象版图中连接到该实例变量的的部分不会被存储。

下面来看如果使用 Deserialization 去还原对象。

在这里插入图片描述

通过上面的流图,我们可以清楚地看到,解序列化 (Deserialization) 实质上就是对序列化 (Serialization) 的反操作。此时 Java 虚拟机会试图在堆 (heap) 上创建新的对象,使其与存储时的对象具有一样的状态,具体的流程如下:

  1. 将对象从 stream 中读出来
  2. Java 虚拟机通过存储的信息判断对象的 class 类型
  3. Java 虚拟机尝试寻找和加载对象的类。如果失败则抛出异常
  4. 新的对象被放在堆上,但是构造函数不会被执行
  5. 如果对象在它的继承树上有个不可序列化的祖先,那么该不可序列化的类及其祖先类的构造函数会被执行
  6. 对象的实例变量会被恢复为序列化时的状态。transient 会被赋值 null 对象引用或 0, false 等默认值

需要注意的是,解序列化会受到一部分修改操作的损害。比如某个序列化对象原本的类删除了实例变量、改变了实例变量的类型、改变了类的继承层次等。我们需要了解,下列操作是不会损害解序列化的:

加入新的实例变量

在继承层次中加入新的类

从继承层次中删除类

将实例变量从瞬时 (transient) 变为非瞬时

除此以外,还需要明确 serialVersionID 的作用。当对象在被序列化时,整个对象版图会被标注一个 serialVersionID,该 ID 根据类的结构信息计算得出。若对象 (object) 在被序列化之后,类 (class) 有了新的 serialVersionID,那么在解序列化时,就会操作失败。

举个例子:

Dog 这个实例是以 ID 23 序列化的。当 Java 还原 Dog 对象时,首先会比对对象 (object) 与 Java 虚拟机上类 (class) 上的 ID,此时就是比对 Dog 对象和 Dog 类的 ID,如果不符,那就会抛出异常

因此,我们可以将 serialVersionID 放入 Dog 类内,保证其在演化过程中维持着不变的 ID。

在这里插入图片描述

将字符串写入文本文件

这里直接使用 FileWriter 即可:

在这里插入图片描述

下面我们主要看看 java.io.File 这个类,这个类代表磁盘上的一个文件路径。

在这里插入图片描述

在使用 FileWriter 可以使用缓冲区来降低对磁盘的 I/O 开销,因为只有当缓冲区满时,才会进行 write() 操作

在这里插入图片描述

同理,我们也可以使用 FileReader 来读取文件,和之前一样,缓冲区 BufferReader 也能够帮助读取环节减少磁盘 I/O 开销

在这里插入图片描述

在这里插入图片描述

网络与线程

本节主要关注 Java 的 socket 编程以及多线程,主要以一个聊天程序作为样例来进行理解。主要会使用 java.net 网络功能包

一个最简单的聊天室程序会选择以 client-server 结构进行构建,此时多个客户端 (client) 会与服务器 (server) 相连,服务器会负责将来自某个客户端的消息转达给正确的对象,以此达成线上聊天的目的。因此,我们可以将这整个过程简单地划分为 “连接 - 传送 - 接收”:

在这里插入图片描述

**由于本篇是 Java 编程回顾,因此在这里只对 socket 做一个大概的描述,详细内容可以去看计算机网络的相关内容。socket 是一个 Application 层与 Transport 层之间的 API,在应用层中的某个应用 (进程) 如果想要发送信息给另一台机器上的某个应用 (进程),那么就需要将信息通过 socket 向下传输,不断封装,再通过网络传递给指定的目标。*

我们在这里可以简单地理解为,我们需要在两台机器之间建立 socket 连接,此时需要指定两个变量:地址 (ip address) 和端口号 (port number)。

在这里插入图片描述

和之前读取序列化输入输出一样,我们仍需使用串流 (stream) 通过 socket 连接来沟通,当然也可以使用 BufferReader 来帮助进一步减少 I/O 开销:

在这里插入图片描述

// Build socket connection to server. Here we use localhost as server
Socket chatSocket = new Socket("127.0.0.1", 5000);
// Build inputStreamReader connect to socket
InputStreamReader strem = new InputStreamReader(chatSocket.getInputStream());
// Build BufferReader to read
BufferReader buffer = new BufferReader(stream);
String info = reader.readLine();

既然能读,那么也就能写。在上一节,我们在输出时,使用了 BufferedWriter,这是因为那是批量输出,我们最终地目标是将所有内容输出并存储。但在使用聊天室时,我们经常需要一条一条发送信息,因此,可以直接使用 PrintWriter 来进行写/输出操作:

在这里插入图片描述

// Same as before, we need build socket connection firstly
Socket chatSock = new Socket("127.0.0.1", 5000);
// Build PrintWriter connect to socket
PrintWriter writer = new PrintWriter(chatSock.getOutputStream());
// Write data
writer.println("message to send");
writer.print("another message");

多线程 (Multi-threading)

不管是 client 还是 server,我们在聊天时希望它们都能同时进行发送和接收两种任务,否则会十分不便,这就需要借助多线程的力量。而想要使用多线程,就需要使用 java.lang 中的 Thread 这个类。

在这里插入图片描述

可以看到,独立的线程 (thread) 就代表独立的执行空间。Java 虚拟机会在线程与原来的主线程之间来回切换,直到两者全部完成。我们需要了解如何启动新的线程:

// Create Runnable object (This is the specific task of thread)
Runnable threadjob1 = new MyRunnable();
// Build Thread object (Hire an employee and assign task to him)
Thread myThread1 = new Thread(threadjob1);
// Activate thread (Force employee to start work)
myThread1.start();

根据上面的代码,我们可以很清晰地了解到:一个线程 (Thread) 就是一位员工,我们为每个员工分配了工作,也就是 Runnable 对象。 那么这时候,就需要实现 Runnable 接口来具体声明分配给 Thread 的任务。

public class MyRunnable implements Runnable {
    // This method must be implemented
    public void run() {
        go();
    }
    
    public void go() {
        doMore();
    }
    
    public void doMore() {
        System.out.println("Start thread job.");
    }
}


class ThreadTest {
    public void main(String[] args) {
        Runnable myThreadJob = new MyRunnable();
        Thread myThread = new Thread(myThreadJob);
        
        myThread.start();
        
        System.out.println("Back in the main function");
    }
}

线程调度器会做出所有决定。比如决定哪个线程从等待状态被挑选出来运行以及将哪个线程变为等待状态。我们无法操控调度器。通常来说它都比较公平,但谁也无法保证,有时候就是会有某些线程更受冷落。就比如上面的的这段代码,有时会是主线程先结束,有时会是新建线程会结束。

为了其他线程有机会执行,可以将某些线程放进睡眠状态。只要使用 sleep() 就能让某个线程在时间到达之前不被唤醒。在时间到达之后,它会重新进入等待被执行的状态,具体何时被执行仍需要看调度器的决定,所以这种机制仍无法精准控制执行时间。

需要注意的是,使用 sleep() 时,最好搭配 try … catch …

在这里插入图片描述

但是线程也存在缺陷,主要在于并发性 (concurrency) 问题,它会进一步引发竞争状态 (race condition)。最典型的例子就是两个或以上的线程存取单一的数据对象,即两个不同执行空间上的方法都在堆 (heap) 上对同一个对象进行 setter 和 getter 操作。

下面举一个具体的例子:

class BankAccount {
    // There are 100 dollars in the account at the beginning
    private int balance = 100;

    public int getBalance() {
        return balance;
    }

    public void withdraw(int amount) {
        balance -= amount;
    }
}

public class RyanAndMonicaJob implements Runnable{
    // Only one account BankAccount instance
    private BankAccount account = new BankAccount();

    public static void main(String[] args) {
        // Initialize the task
        RyanAndMonicaJob theJob = new RyanAndMonicaJob();

        // Create 2 threads take the same job
        Thread one = new Thread(theJob);
        Thread two = new Thread(theJob);
        one.setName("Ryan");
        two.setName("Monica");
        one.start();
        two.start();
    }

    public void run() {
        for (int i = 0; i < 10; i++) {
            makeWithdraw(10);
            if (account.getBalance() < 0) {
                System.out.println("Overdrawn!");
            }
        }
    }

    // Add synchronized to ensure this method will be use by only one thread at one time
    private synchronized void makeWithdraw(int amount) {
        if (account.getBalance() >= amount) {
            System.out.println(Thread.currentThread().getName() + " is about to withdraw.");
            try {
                System.out.println(Thread.currentThread().getName() + " is going to sleep.");
                Thread.sleep(500);
            }
            catch (InterruptedException ex) {
                ex.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " woke up");
            account.withdraw(amount);
            System.out.println(Thread.currentThread().getName() + " complete the withdraw!");
        }
        else {
            System.out.println("Sorry, not enough for " + Thread.currentThread().getName());
        }
    }
}

这个代码会产生之前所说的并发性问题,具体情况为:

Ryan 首先检查余额,发现足够,进入沉睡;

同时 Monica 也来检查余额,发现足够,进入沉睡;

Ryan 醒来,完成取款;

Monica 醒来,发现提示余额不足,无法取款

这就和数据库中的并发性问题同样的性质,因此我们也能够使用锁 (Lock) 机制来应对这个问题。还是用上面的这个例子来理解。我们现在希望确保线程一旦开始检查余额,就要确定它会在任何其他线程检查余额之前,就完成取款操作!

此时,我们可以使用 synchronized 这个关键词来修饰方法,使它一次只能被单一的线程存取。换句话说,一旦线程进入该方法,我们可以确保在其他线程进入该方法之前,所有工作都已经完成。 (如同原子一般不可被分隔,这就是 “原子性” 的基本含义)

但是这里与数据库一样,需要格外注意 “死锁 (Dead Lock)” 的问题,这个问题主要体现在对 2 个方法都进行了同步化,此时如果两个线程分别都需要进入对方正处于其中的方法,那么就会陷入无限等待。

在这里插入图片描述

数据结构

我们在此之前已经介绍过 ArrayList 这种 Java 常见的数据结构,当我们使用该结构时,其内部元素的顺序即为插入的顺序,现在我们来看看 Java 的其他几种数据结构:

在这里插入图片描述

为了获得有序的结果/存储,我们可以选择使用 TreeSet,根据名字就能直观地了解到它是一个树状结构,每当插入新元素时,就会首先确定合适的放置位置,以保证其内部的元素总是按顺序排列的,但也正因如此,它的成本会比 ArrayList 要高。

另一种获得有序结果的方法是使用 Collections 类中的 sot() 方法,我们可以将 ArrayList 传入这个方法:Collections.sort(ArrayList)

但如果我们在 ArrayList 中存储的不是一个 Primitive 主数据类型的元素,比如 String, int … 而是对象,该怎么进行排序?比如 ArrayList <Song> 这表明此时的 ArrayList 中存储的全都是 Dog 对象,如果直接将这样一个 ArrayList 传递给 Collections.sort() 就会报错。这是因为 sort() 只接受 comparable 对象的 List。因此,我们如果相对 ArrayList <Song> 进行排序,就必须实现 comparable。 这实际上就是在告诉 Java 虚拟机,我们使用 Song 这个类里的什么东西去进行比较:

class Song implements Comparable<Song> {
    String title;
    String artist;
    String rating;
    String bpm;
    
    public int compareTo(Song s) {
        return title.compareTo(s.title);
    }
    
    ...
}

此时就能够成功使用 sort() 依据歌名来为 Song 对象进行排序。那么如果我们希望同时根据歌名和演唱者这两个实例变量进行排序该怎么办?此时我们可以指定 comparator 并同时传递给 sort() 方法,大概总结一下:

sort(List o):使用单一参数的 sort() 方法代表由 list 元素上的 compareTo() 方法来决定顺序

sort(List o, Comparator c):代表不会用 list 的 compareTo() 方法,而是使用 comparator 的 compare() 方法。因此 list 元素不需要实现 compareTo() 方法

import java.util.*;
import java.io.*;

public class JukeBox {
    ArrayList<Song> songList = new ArrayList<song>();
    public static void main(String[] args) {
        new JukeBox().go()
    }
    
    // Here we define an inner class to realize Comparator class
    class ArtistCompare implements Comparator<Song> {
        public int compare(Song a, Song b) {
            return a.getArtist().compareTo(b.getArtist());
        }
    }
    
    public void go() {
        getSongs();
        // Sort by 'title' (compareTo())
        System.out.println(songList);
        Collections.sort(songList);
        System.out.println(songList);
        
        // Sort by artist
        ArtistCompare artistCompare = new ArtistCompare();
        Collections.sort(songList, artistCompare);
        System.out.println(songList);
    }  
    
    void getSongs() {
        // I/O
    }
    
    void addSong(String lineToParse) {
        // parse song list
    }
}

当然,我们也可以选择完全去除 Song 类的 compareTo() 方法,然后对 sort() 指定两个 comparatpors 达成相同的效果。在了解了 compareTo() 和 comparators 的存在之后,我们稍微回顾一下之前提及的 TreeSet,我们已经知道这个结构始终会维护元素的顺序,所以这也就意味着他必须满足以下两个条件之一:

  1. 集合中的元素必须是有实现 Comparable 的类型
  2. 使用重载、取用 Comparator 参数的构造函数来创建 TreeSet

解决了排序问题之后,下一个比较常见的问题是去重,这里和其他编程语言一样,我们需要借助 set 这个数据结构

在这里插入图片描述

在这里插入图片描述

我们可以直接将 ArrayList 的元素全部放入 HashSet

import java.util.*;
import java.io.*;

public class JukeBox2 {
    ArrayList<Song> songList = new ArrayList<Song>();
    // main method etc.
    
    public void go() {
        getSongs();
        System.out.println(songList);
        Collections.sort(songList);
        System.out.println(songList);
        
        // Build HashSet
        HashSet<Song> songSet = new HashSet<Song>();
        songSet.addAll(songList);
        System.out.printlb(songSet);
    }
}

但如果直接这样打印,我们会发现,结果只能可能还是会有重复的结果。这就需要考虑引用相等性和对象相等性

在这里插入图片描述

HashSet 会使用 HashCode 和 equals() 来判断重复。当我们将对象加入 HashSet 时,它会首先使用加入对象的 HashCode 与现有对象的 HashCode 作比对,如果没有相符的,就假定没有重复。如果发现某个已有对象的 HashCode 相同,那么就会再调用 equals() 来检查二者是否真的相同。我们也可以使用 add() 来进行判断,如果 add() 返回 false,那么就表明集合中有重复对象。

了解了比对机制,我们就明白了该怎么修改代码:我们需要覆盖对象的类中的 hashCode() 和 equals():

在这里插入图片描述

现在来看一看 Map,这个数据结构和 Python 中的字典比较相似,同样是以 (key, value) 对的形式存储数据的,虽然 key 通常是 String,但也可以使用其他的对象:

在这里插入图片描述

最后我们再来关注一下泛型,我们在创建 ArrayList 时,使用 <> 声明了其元素的类型,这就是泛型,需要注意,如果我们将方法声明为取用 ArrayList<Animal>,它就只会取用 ArrayList<Animal>ArrayList<Dog>ArrayList<Cat> 都不行。但根据多态,如果声明是单纯的数组 Aniaml[],那么该方法是可以取用 Dog[] 的。

我们可以通过使用泛型的万用字符来强制创建出接收 Animal 子类参数的方法:

在这里插入图片描述

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值