Java核心技术卷I (2)继承、接口、异常

《Java核心技术卷I》阅读笔记 ——继承、接口与异常

继承

类、超类、子类

关系“is-a”是继承的一个明显特征

定义子类

1
2
3
4
public class Manager extends Employee
{
//添加方法和域
}
  • 在 Java 中,所有的继承都是公有继承
  • 已存在的类称为超类、基类或父类;新类称为子类、派生类或孩子类——子类比超类拥有的功能更加丰富
  • Manager 类自动地继承了超类 Employee 中的方法和域

覆盖方法(override)

  • 超类中的有些方法对子类并不适用

  • 但覆盖后的方法不能够直接地访问超类的私有域,只有 Employee 类的方法才能够访问私有部分

  • 用 super 指明是调用超类中的同名同参数方法

    1
    2
    3
    4
    5
    public double getSalary()
    {
    double baseSalary = super.getSalary();
    return baseSalary + bonus;
    }
  • super 不是一个对象的引用,this 是对象的引用

  • 子类中可以增加域、增加方法或覆盖超类的方法,但不能删除超类的方法

子类构造器

1
2
3
4
5
6
public Manager(String name, double salary, int year, int month, int day)
{
//调用超类 Employee中含有 n、s、year、month 和 day 参数的构造器
super(name, salary, year, month, day);
bonus = 0;
}
  • Manager 类的构造器不能访问 Employee 类的私有域,必须利用 Employee 类的构造器对这部分私有域进行初始化,且必须是第一条语句

  • 如果子类的构造器没有显式地调用超类的构造器,则将自动地调用超类默认(没有参数)的构造器

  • 一个对象变量可以指示多种实际类型的现象被称为多态(一个变量可以指示父类,也可以指示子类,如e.getSalary()可以指父类的方法,也可以指子类的方法)

    1
    2
    3
    4
    5
    6
    Employee[] staff = new Employee[3];
    staff[0] = new Manager("Carl Cracker", 80000, 1987, 12, 15);
    staff[1] = new Employee("Harry Hacker", 50000, 1989, 10, 1 );
    staff[2] = new Employee("Tony Tester", 40000, 1990, 3, 15);
    for (Employee e : staff)
    System.out.println(e.getName() + " "+ e.getSalary());

继承层次

  • 由一个公共超类派生出来的所有类的集合被称为继承层次

  • 从某个特定的类到其祖先的路径被称为该类的继承链

    image-20210202161727726
  • Java 不支持多继承

多态

  • “is-a”规则的另一种表述法是置换法则,表明程序中出现超类对象的任何地方都可以用子类对象置换

    1
    2
    3
    Employee e;
    e = new Employee(...); // Employee object expected
    e = new Manager(...); // OK, Manager can be used as well
  • 一个 Employee 变量既可以引用一个 Employee 类对象,也可以引用一个 Employee 类的任何一个子类的对象

  • 不能将一个超类的引用赋给子类变量

    1
    2
    3
    //非法
    Manager m = staff[i]; //Error
    staff[0].setBonus (5000); //Error
  • 子类数组的引用可以转换成超类数组的引用

    1
    2
    Manager[] managers = new Manager[10];
    Employee[] staff = managers; //OK

方法的调用过程

  • x.f(args)为例
  • 编译器査看对象的声明类型和方法名;编译器会一一列举该类中名为 f 的方法和其超类中访问属性为 public 且名为 f 的方——注意,如果在子类中定义了一个与超类签名相同的方法,子类中的这个方法会覆盖超类中的这个相同签名的方法
  • 编译器将査看调用方法时提供的参数类型
  • 以上为动态绑定
    • 虚拟机调用与 x 所引用对象的实际类型最合适的那个类的方法,先看实际类型,再看父类
    • 虚拟机预先为每个类创建了一个方法表,列出所有方法的签名和实际调用的方法
  • 如果是 private 方法、static 方法、final 方法,如果是 private 方法、static 方法、final 方法——静态绑定
  • 覆盖一个方法的时候,子类方法不能低于超类方法的可见性
    • 如果超类方法是 public, 子类方法一定要声明为 public

阻止继承

  • 不允许扩展的类被称为 final 类

    1
    2
    3
    4
    public final class Executive extends Manager
    {
    ...
    }
  • 特定方法也可以被声明为 final,此时子类不能覆盖这个方法(final 类的所有方法自动地成为 final 方法,但域不自动转)

强制类型转换

1
Manager boss = (Manager)staff[0]:
  • 暂时忽视对象的实际类型之后,使用对象的全部功能

  • 将一个超类的引用赋给一个子类变量,必须进行类型转换

  • 在进行类型转换之前,应该使用 instanceof 进行检查

    1
    2
    3
    4
    5
    if (staff[1] instanceof Manager)
    {
    boss = (Manager)staff[1];
    。。。
    }
  • 只能在继承层次内进行类型转换

  • 一般情况下,应该尽量少用类型转换

抽象类

1
2
3
4
5
public abstract class Person
{
...
public abstract String getDescriptionO;
}
  • 使用 abstract 关键字,则完全不需要实现这个方法
  • 包含一个或多个抽象方法的类本身必须被声明为抽象的
  • 抽象类还可以包含具体数据和具体方法
  • 抽象方法充当占位的角色,具体实现在子类中;如果子类没有定义所有的抽象方法,子类也必须设置为抽象类
  • 抽象类不能被实例化;可以定义一个抽象类的对象变量,但是只能引用非抽象子类的对象

受保护访问

  • 有些时候,希望超类中的某些方法(域)被子类访问,则需要将这些方法或域声明为 protected
  • Java 中的受保护部分对所有子类及同一个包中的所有其他类都可见

Object 类

  • Java 中所有类的始祖

  • 可以使用 Object 类型的变量引用任何类型的对象,但具体的操作必须进行类型转换

    1
    2
    Object obj = new Employee("Harry Hacker", 35000);
    Employee e = (Employee)obj;
  • Java 中只有基本类型不是对象

  • 所有的数组类塱都是 Object 的子类

equals()

  • 检测一个对象是否等于另外一个对象——判断两个对象是否具有相同的引用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class Employee
    {
    public boolean equals(Object otherObject)
    {
    //a quick test to see if the objects are identical
    if (this == otherObject) return true;
    //must return false if the explicit parameter is null
    if (otherObject == null) return false;
    //if the classes don't match, they can't be equal
    if (getClass() != otherObject.getClass())
    return false;
    //now we know otherObject is a non-null Employee
    Employee other = (Employee)otherObject;
    // test whether the fields have identical values
    return name.equals(other.name)
    && salary = other.salary
    && hireDay.equals(other.hireDay);
    }
    }
  • 在子类中定义 equals 方法时,首先调用超类的 equals

  • 以上的顺序需要着重注意

  • 用 getClass 不用 instanceof 是因为没有解决子类的情况

hashCode()

  • 由对象导出的一个整型值

    1
    2
    3
    4
    5
    6
    public int hashCode()
    {
    return 7 * Objects.hashCode(name)
    + 11* Double.hashCode(salary)
    + 13 * Objects.hashCode(hireDay);
    }
  • 需要组合多个散列值时,可以调用 Objects.hash 并提供多个参数

    1
    2
    3
    4
    public inthashCode()
    {
    return Objects.hash(name, salary, hireDay);
    }
  • 如果存在数组类型的域,可以使用静态的 Arrays.hashCode 方法计算一个散列码

toString()

  • 返回表示对象值的字符串

  • 绝大多数(但不是全部)的 toString 遵循格式:类的名字 + 一对方括号括起来的域值

  • 最好通过调用 getClass().getName() 获得类名

    1
    2
    3
    4
    5
    6
    7
    8
    public String toString()
    {
    return getClass().getName()
    + "[name=" + name
    + ",salary="+ salary
    + ",hireDay=" + hireDay
    + "]";
    }
  • 只要对象与一个字符串通过操作符“+”连接起来,Java 编译就会自动地调用 toString

  • println 方法会直接调用 toString

泛型数组列表

  • Java 允许在运行时确定数组的大小 Employee[] staff=new Employee[actualSize];

  • 使用 Java 中 ArrayList 类——采用类型参数的泛型类,需要用一对尖括号将类名括起来加在后面

    1
    ArrayList<Employee> staff = new ArrayList();
  • 使用 add 将元素添加到数组列表

    1
    staff.add(new Employee("Harry Hacker",...));
    • 如果调用 add 且内部数组已满,数组列表将自动地创建一个更大的数组,并将所有的对象从较小的数组中拷贝到较大的数组

    • 可以在填充数组之前调用 ensureCapacity,分配一个内部数组并调用多次 add

      1
      2
      3
      staff.ensureCapacity(100);
      //或者
      ArrayList<Employee> staff = new ArrayList(100);
  • 完成初始化构造之后,数组列表不含有任何元素

  • staff.size() 返回 staff 数组列表的当前元素数量

  • 确认数组列表的大小不再发生变化,调用 trimToSize,存储区域的大小调整为当前元素数量所需要的存储空间数目,回收多余空间

访问数组列表元素

  • 增加了访问元素语法的复杂度——使用 get 和 set 实现访问或改变数组元素,不能用 [ ]

    1
    2
    staff.set(i, harry); //a[i] = harry;
    Employee e = staff.get(i); //Employee e = a[i];
  • 只有 i 小于或等于数组列表的大小(不是 capicity!)时,才能调用 set

  • 删除元素:remove,返回被删除的元素,参数为被删除元素的下标

类型化与原始数组列表兼容性

1
2
3
4
5
6
7
public class EmployeeDB
{
public void update(ArrayList list) {...}
public ArrayList find(String query) {...}
}
ArrayList<Employee> staff = . . .;
employeeDB.update(staff);
  • 以上并不安全(数组列表元素可能不是 Employee 类型),但不会给出警告

  • 将一个原始 ArrayList 赋给一个类型化 ArrayList 会得到一个警告 ArrayList<Employee> result = employeeDB.find(query);,此时使用类型转换并不能避免出现警告

  • 使用标注来标记这个变量能接受类型转换

    1
    2
    @SuppressWarnings("unchecked") ArrayList<Employee> result=
    (ArrayList<Employee>) employeeDB.find(query);//yields another warning

对象包装器与自动装箱

  • 将 int 这样的基本类型转换为对象——所有的基本类型都冇一个与之对应的类,即包装器( wrapper)
  • Integer、Long、Float、Double、Short、Byte、Character、Void 和 Boolean
  • 一旦构造了包装器,就不允许更改包装在其中的值
  • 不能定义包装器的子类
  • 定义一个整型数组列表:ArrayList<Integer> list = new ArrayList<>();
  • list.add(3);将自动变换为list.add(Integer.value0f(3));,即自动装箱;而将 Integer 对象赋给 int 时,会自动拆箱int n = list.get(i);等价于int n = list.get(i).intValue();
  • 算术表达式中也能够自动地装箱和拆箱
  • 如果在一个条件表达式中混合使用 Integer 和 Double类型,则会先拆箱、提升类型、再装箱
  • 装箱和拆箱是编译器认可的,而不是虚拟机
  • 数字字符串转为数值:int x = Integer.parselnt(s);

方法参数数量可变

  • printf 方法:

    1
    2
    3
    4
    public class PrintStream
    {
    public PrintStream printf(String fmt, Object... args) { return format(fmt, args); }
    }
  • 省略号表明这个方法可以接收任意数量的对象

  • 方法接收两个参数,一个是格式字符串,另一个是Object[ ] 数组 args

  • 类似的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public static double max(double... values)
    {
    double largest = Double.NEGATIVE_INFINITY;
    for (double v : values)
    if (v > largest)
    largest = v;
    return largest;
    }
    double m = max(3.1, 40.4, -5);

枚举类

  • public enum Size { SMALL, MEDIUM, LARGE, EXTRA_LARGE};定义的类型是一个类,刚好有 4 个实例
  • 比较两个枚举类型的值时,可以直接用 ==
  • 可以在枚举类型中添加一些构造器、方法和域

反射

反射库(reflection library)提供了一个非常丰富且精心设计的工具集;能够分析类能力的程序称为反射

Class 类

  • Java 运行时系统始终为所有的对象维护一个被称为运行时的类型标识,这个信息跟踪着每个对象所属的类。保存这些信息的类被称为 Class

  • Object 类中的`getClass()将会返回一个 Class 类型的实例;最常用的 Class方法是 getName

    1
    2
    System.out.println(e.getClass().getName() + " " + e.getName());
    //打印Employee Harry Hacker
  • 如果类在一个包里,包的名字也作为类名的一部分

  • 调用静态方法 forName 获得类名对应的 Class 对象——只有在 className 是类名或接口名时才能够执行

    1
    2
    String className = "java.util.Random";
    Class cl = Class.forName(className);
  • T 是任意的 Java 类型,T.class 代表匹配的类对象

捕获异常

  • 异常有两种类型:未检查异常和已检查异常;对于已检查异常,编译器将会检查是否提供了处理器

  • 将可能抛出已检査异常的一个或多个方法调用代码放在 try 块中,在 catch子句中提供处理器代码

    1
    2
    3
    4
    5
    6
    7
    8
    try
    {
    statements that might throw exceptions
    }
    catch (Exception e)
    {
    handler action
    }

利用反射分析类的能力

  • java.lang.reflect 包中三个类 Field、Method 和 Constructor 分别用于描述类的域、方法和构造器
  • Field:
    • getName 方法
    • getType 方法
    • getModifiers 方法:返回整数值,用不同的位开关描述 public 和 static 这样的修饰符使用状况
  • Method:
    • getName 方法
    • 报告参数类型
    • 报告返回类型
    • getModifiers 方法:返回整数值,用不同的位开关描述 public 和 static 这样的修饰符使用状况
  • Constructor:
    • getName 方法
    • 报告参数类型
    • getModifiers 方法:返回整数值,用不同的位开关描述 public 和 static 这样的修饰符使用状况
  • 利用 java.lang.reflect 包 Modifier 类的静态方法分析 getModifiers 返回的整型数值
  • Class 类中的 getFields、getMethods 和 getConstructors 方法将分别返回类提供的 public 域、方法和构造器数组,包括超类公有成员
  • Class 类 getDeclareFields、getDeclareMethods 和 getDeclaredConstructors 方法将分别返回类中声明的全部域、方法和构
    造器,其中包括私有和受保护成员,但不包括超类的成员

使用反射分析对象

  • 查看对象域的关键方法是 Field 类中的 get 方法

使用反射编写泛型数组

调用任意方法

继承的设计

  • 将公共操作和域放在超类

  • 不要使用受保护的域

  • 使用继承实现“is-a”关系

  • 除非所有继承的方法都有意义,否则不要使用继承

  • 覆盖方法时,不要改变预期的行为

  • 使用多态,而非类型信息

    1
    2
    3
    4
    if (x is of type1)
    action1(x)
    elseif (x is of type2)
    action2(x);

    对于上面这种形式的代码,考虑使用多态性

  • 不要过多地使用反射

接口、lambda表达式

接口

接口概念

  • 接口不是类,而是对类的一组需求描述,这些类要遵从接口描述的统一格式进行定义

  • 接口中的所有方法自动地属于 public。因此在接口中声明方法时,不必提供关键字 public

  • 接口可能包含多个方法,也可以定义常量

  • 接口绝不能含有实例域

  • 举例:任何实现 Comparable 接口的类都需要包含 compareTo 方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public interface Comparable
    {
    int compareTo(Object other);
    }
    class Employee implements Comparable<Employee>
    {
    public int compareTo(Employee other)
    {
    return Double.compare(salary, other.salary);
    }
    }
    • 将类声明为实现给定的接口
    • 对接口中的所有方法进行定义
    • 在实现接口时,必须把方法声明为 public
  • 可以将接口看成是没有实例域的抽象类

接口特性

  • 接口不是类,不能使用 new 实例化一个接口;但能声明接口的变量,接口变量必须引用实现了接口的类对象

  • 可以用 instanceof 检查一个对象是否实现了某个特定的接口 if(anObject instanceof Comparable){...}

  • 接口也可以被扩展

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public interface Moveable
    {
    void move(double x, double y);
    }
    public interface Powered extends Moveable
    {
    double milesPerGallon();
    double SPEED_LIMIT = 95; ////a public static final constant
    }
  • 接口中的域将被自动设为 public static final

  • 每个类只能够拥有一个超类,但却可以实现多个接口

    1
    class Employee implements Cloneable, Comparable

接口与抽象类

  • 如果使用抽象类表示通用属性,则每个类只能扩展于一个类
  • 接口可以提供多重继承的大多数好处,还能避免多重继承的复杂性和低效性

静态方法

  • 允许在接口中增加静态方法,但有违于将接口作为抽象规范的初衷
  • 通常的做法都是将静态方法放在伴随类

默认方法

  • 为接口方法提供一个默认实现,必须用 default 修饰符标记

    1
    2
    3
    4
    5
    public interface Comparable<T>
    {
    default int compareTo(T other) { return 0; }
    //By default, all elements are the same
    }
  • 默认方法可以调用任何其他方法

  • 事实上每一个实际实现都要覆盖这个方法

解决默认方法冲突

  • 先在一个接口中将一个方法定义为默认方法,又在超类或另一个接口中定义了同样的方法

    • 超类优先:同名且参数类型相同的默认方法被忽略
    • 接口冲突:无论是否默认,都必须覆盖这个方法解决冲突
  • 在两个冲突方法中选择一个

    1
    2
    3
    4
    class Student implements Person, Named
    {
    public String getName() { return Person.super.getName(); }
    }
  • 如果两个接口都没有为共享方法提供默认实现,则不存在冲突,此时可以实现这个方法,或者不实现——此时类为抽象类

接口示例

接口与回调

  • 回调(callback)可以指出某个特定事件发生时应该采取的动作

  • 例如,实现一个定时器,到达时间间隔时做某某操作——将一个类的对象传递给定时器,定时器调用这个对象的方法(定时器需要知道调用哪个方法,并且要求对象所属的类,实现了java.awt.event包的ActionListener接口)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public interface ActionListener
    {
    void actionPerfonned(ActionEvent event);
    }

    class TimePrinter implements ActionListener
    {
    public void actionPerformed(ActionEvent event)
    {
    System.out.println("At the time, the time is " + new Date())
    Toolkit.getDefaultToolkit().beep();
    }
    }

Comparator 接口

对象克隆

深拷贝,略

lambda表达式

语法

  • 参数,->,一个表达式或者{}包含的代码块:

    1
    2
    3
    4
    5
    6
    7
    8
    (String first, String second)
    -> first.length() - second.length()
    (String first, String second) ->
    {
    if (first.length() < second.length()) return -1;
    else if (first.length() > second.length()) return 1;
    else return 0;
    }
  • 即使表达式没有参数, 仍然要提供空括号:() -> { for (int i = 100;i >= 0;i--) System.out.println(i);}

  • 无需指定 lambda 表达式的返回类型

  • 一个 lambda 表达式只在某些分支返回一个值, 在另外一些分支不返回值, 是不合法的,例如(int x) -> { if(x >= 0) return 1; }

函数式接口

  • 只有一个抽象方法的接口

    1
    2
    3
    4
    5
    Timer t = new Timer(1000, event ->
    {
    System.out.println("At the time, the time is " + new Date());
    Toolkit.getDefaultToolkit().beep();
    };

内部类

  • 定义在另一个类中的类

    • 内部类方法可以访问该类定义所在的作用域的数据
    • 对同一个包中的其他类隐藏起来
  • 可以在一个方法中定义局部类——局部类不能用 public 或 private 访问说明符进行声明,其作用域被限定在声明这个局部类的块中,类中的其他代码也不能访问它

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public void start()
    {
    class TimePrinter implements ActionListener
    {
    public void actionPerforaed(ActionEvent event)
    {
    System.out.println("At the tone, the time is " + new Date());
    if (beep) Toolkit.getDefaultToolkit().beep():
    }
    }
    ActionListener listener = new TimePrinter();
    Timer t = new Timer(interva1, listener);
    t.start();
    }

匿名内部类

  • 只创建这个类的一个对象,就不必命名

    image-20220708143140742
  • 匿名类不能有构造器,而是将构造器参数传递给超类(superclass)构造器

  • 习惯做法是用匿名内部类实现事件监听器和其他回调

静态内部类

  • 不需要内部类引用外围类对象时,可以将内部类声明为 static
  • 静态内部类可以有静态域和方法
  • 声明在接口中的内部类自动成为 static 和 public 类

代理

异常、断言和日志

异常

  • 如果某个方法不能采用正常的途径完整任务,可以通过另外一个路径退出方法,此时不返回任何值, 而是抛出 (throw) 一个封装了错误信息的对象,异常处理机制搜索能够处理这种异常的异常处理器(exception handler)

    image-20220708143958644
    • Error类描述运行时系统的内部错误和资源耗尽错误,如果出现这样的错误,基本无法处理
    • Exception类分为两个分支:
      • 一个分支派生于RuntimeException(程序错误导致的异常),包括错误的类型转换、数组访问越界、访问 null 指针等
      • 一个分支包含其他异常(程序没有问题),包括在文件尾部后面读取数据、打开一个不存在的文件、某个字符串表示的类并不存在等
  • 派生于Error类或 RuntimeException 类的所有异常称为非受查 ( unchecked ) 异常,所有其他的异常称为受查( checked) 异常

声明受查异常

  • 方法应该在其首部声明所有可能抛出的异常,例如public Fi1elnputStream(String name) throws FileNotFoundException

  • 遇到下面四种情况时要抛出异常——抛出后即可不必理会

    • 调用一个抛出受査异常的方法
    • 程序运行过程中发现错误
    • 程序出现错误
    • Java 虚拟机和运行时库出现的内部错误
  • 除了声明异常之外, 还可以捕获异常,使异常不被抛到方法之外

  • 对于一个异常,找到一个合适的异常类,创建这个类的一个对象,将对象抛出

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    String readData(Scanner in) throws EOFException
    {
    ...
    while (...)
    {
    if (!in.hasNext()) // EOF encountered
    {
    if (n < len)
    throw new EOFException();
    }
    }
    }

创建异常类

  • 定义一个派生于 Exception 的类

  • 应该包含两个构造器:默认构造器和带有详细描述信息的构造器

    image-20220708183843267

捕获异常

捕获异常

  • try、catch语句

  • 例如:

    image-20220708185130955
  • 如果不捕获异常,则代码需要声明这个方法可能会抛出一个IO异常

    image-20220708185251447
  • 如果编写一个覆盖超类的方法,方法没有抛出异常,则这个方法必须捕获方法代码中出现的每一个受查异常——不允许子类的 throws 说明符中,出现超过超类方法所列出的异常类范围

合并catch语句

image-20220708191623101

再次抛出异常

  • catch 子句中可以抛出一个异常,或者将原始的异常设置为新异常的“原因”

    image-20220708192503250

finally

  • 不管是否有异常被捕获,finally 子句中的代码都被执行
  • try 语句可以只有 finally 子句,而没有 catch 子句

带资源的try语句

  • 只要需要关闭资源, 就尽可能使用带资源的 try 语句

    image-20220708214755221
  • printStackTrace 方法访问堆栈轨迹的文本描述信息

异常机制的技巧

  • 异常处理不能代替简单的测试

  • 不要过分地细化异常

  • 利用异常层次结构

  • 早抛出,晚捕获

断言

  • assert 条件;assert 条件:表达式; (类似python中的assert)

  • 默认情况下断言被禁用

    image-20220709132318006

日志

  • 使用全局日志并调用其info方法:Logger.getGlobal().info("File xxx")
  • 一般不将所有的日志都记录到一个全局日志记录器中,可以自定义日志记录器
    • 调用getLogger创建、获取记录器:private static final Logger myLogger = Logger.getLogger("com.mycompany.myapp"):
    • 日志记录器的父与子将共享某些属性。例如, 对 com.mycompany 日志记录器设置日志级别,子记录器也会继承这个级别
    • 日志记录器的级别:
      • SEVERE、WARNING、INFO、CONFIG、FINE、FINER、FINEST
      • 默认只记录前三个级别,可通过设置修改日志记录器级别:logger.setLevel(Level.FINE)
  • 记录日志的常见用途是记录那些不可预料的异常
  • 过滤器根据日志记录的级别进行过滤。每个日志记录器和处理器都可以有一个可选的过滤器完成附加的过滤(调用setFilter方法)
  • 格式化:ConsoleHandler 类和 FileHandler 类可以生成文本和 XML 格式的日志记录,自定义格式需要扩展 Formatter 类并覆盖方法String format(LogRecord record)