Java核心技术阅读笔记(第六章)

chapter 6 接口、lambda 表达式与内部类


作者:Denis

版本:1.0

完成时间:2023/1/1

编写地点:中国山西省

6.1 接口


接口用来描述类应该做什么,而不指定如何去做。一个类可以实现(implement)一个或多个接口。接口中所有的方法自动是public,无需重复声明。接口也可以定义常量(public static final

接口绝不会有实例字段,这很好理解,因为接口并不能直接当类处理。在Java8之前,接口不能有实现了的具体方法。提供实例字段和方法实现的任务应该由实现了接口的类去完成。

在Java8中,允许在接口中添加静态方法。在Java9中,接口中允许静态方法或实例方法被修饰为private,但用法很有限只能作为其他方法的辅助方法,且必须提供方法体。

6.1.1 Comparable接口


这个接口只有一个实现方法,实现了这个接口的类的对象可以比较大小。

public interface Comparable<T> {
    public int compareTo(T o);
}

可以观察到,这个接口有泛型参数,指定具体泛型后可以省略强制类型转换。compareTo方法应该与equals方法兼容。但这里有一个例外,请看下面代码。

public class BigDecimalTest {
    public static void main(String[] args) {
        var x = new BigDecimal("1.0");
        var y = new BigDecimal("1.00");
        //false
        System.out.println(x.equals(y));
        //0
        System.out.println(x.compareTo(y));
    }
}

造成这种结果的原因是这两个数的精度不同。

建议应该常用compareTo方法,理由如下:

  • 如果比较两个整数的大小,可以采用相减的办法,但容易溢出。使用Integer.compare就不会产生这个问题
  • 如果比较两个相差不大的浮点数,直接相减可能为0,使用Double.compare就不会产生这个问题

注意:如果类与类之间存在继承关系,子类的compareTo方法绝不能仅仅将父类强制转换为子类。这里引用一个员工和经理的例子。假设程序中有员工类Employee和经理类Manager,经理也是一种员工,故而继承了Employee。那么经理的compareTo方法就不能把员工对象强转为经理对象。

补救方法如下:

  • 如果子类之间比较的原则各不相同,比如普通员工比较工资多少,经理们比较奖金多少,就各写各的。并禁止不同的类对象进行比较。
if(getClass()!=other.getClass())
	throw new ClassCastException();
  • 如果是通用的比较方式,假如所有的员工都比较基本工资,就在基类声明compareTo方法即可,并添加final修饰符。

6.1.2 Cloneable接口


如果类实现了Cloneable接口,并且重写了clone()函数,那么该类的对象就可以被克隆出一个副本。

对于浅拷贝,只能拷贝基本字段,如果对象中另一个子对象,那么新的副本和原来的对象就会共享这个子对象。如果是不可变的子对象(例如LocalDate、String)就是安全的。反之,如果包含可变的子对象,浅拷贝之后的对象如果对子对象操作会影响原来的对象,就需要采用深拷贝了。

浅拷贝的代码很短,参考下面代码:

class User implements Serializable,Cloneable{

    private String name;
    private Integer age;

    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
}

深拷贝则需要做更多的工作:

class User implements Serializable,Cloneable{

    private String name;
    private Integer age;
    private Date birthday;

    @Override
    public User clone() throws CloneNotSupportedException {
        var cloned = (User)super.clone();
        cloned.birthday = (Date) this.birthday.clone();
        return cloned;
    }
}

需要注意的是,这里将clone()方法的访问修饰符设置为public,那么子类就可以直接调用这个方法。这么做存在一些问题:如果子类只是比父类多扩展了基本字段,就没有问题。但是如果子类包含了不可克隆的子对象,就会抛出一个异常。出于这个原因,Object将clone()方法的访问修饰符设置为protected。

克隆操作其实并不常用,标准库只有不到5%的类实现了克隆。

所有的数组类型都有一个公共的clone()方法,可以直接创建一个新数组,包含原来所有元素的副本。

int[] luckyNumbers = {2, 3, 5, 7, 11, 13};
int[] cloned = luckyNumbers.clone();

6.1.3 解决默认方法冲突


如果在接口将一个方法定义为默认方法,然后又在超类或另一个接口定义同样的方法,会造成默认方法冲突,Java对于这种情况采用如下处理方式。

  • 超类优先,如果超类有具体的方法,其他方法签名相同的方法会被忽略。
  • 如果没有继承超类,而是实现了两个以上具有相同方法签名的接口。那么需要在实现类中重写这个方法,然后可以指定使用哪个接口的默认实现方法。
public class Student implements BB,CC{
	//BB, CC接口都有getName的默认实现方法,需要重写getName方法
    @Override
    public String getName() {
        //指定使用CC的方法
        return CC.super.getName();
    }
}
public class Student extends AA implements BB,CC{
    //超类有具体实现,就使用超类的getName方法,不需要重写
    public static void main(String[] args) {
        System.out.println(new Student().getName());
    }
}

6.2 lambda表达式


lambda表达式是一种采用简洁的语法定义的代码块,可以传递并在以后执行一次或多次。在一定程度上可以取代回调函数。

如果要检查一个字符串是否比另一个字符串长,应该用如下代码:

first.length() - second.length();

使用lambda表达式,可以这样写

(String first, String second) -> first.length() - second.length();

可以看到,lambda表达式由参数、箭头、以及一个表达式组成,如果无法用一行表达式解决问题,可以将代码放在代码块({})里,例如:

(String first, String second) -> 
{
    if(first.length() < second.length())
		return -1;
 	else if(first.length() > second.length())
     	return 1;
 	else return 0;
}

即使lambda表达式没有参数,仍然需要提供一对空括号,像无参方法一样。

() -> {for(int i = 100; i >= 0; i--) System.out.println(i);}

如果参数类型可由编译器推导出来,那么可以省略参数类型:

Comparator<String> comp = (first, second) -> first.length() - second.length();

如果只有一个参数,且参数类型可推导,还可以省略小括号。

//ActionListener是一个接口,只有一个方法
ActionListener listener = 
                event -> System.out.println("The time is " + Instant.ofEpochMilli(event.getWhen()));

无需指定lambda表达式的返回类型,总是会由上下文推导出。

//在需要int类型的上下文使用
(String first, String second) -> first.length() - second.length();

6.2.1 函数式接口


对只有一个抽象方法的接口,在需要这种接口的实现类对象时,就可以提供一个lambda表达式。这种接口被称为函数式接口。上文提到的ActionListener、Comparator都是函数式接口。换言之,lambda表达式可以转换为函数式接口。

java.util.function包中定义了许多常用的函数式接口。其中一个接口BiFunction<T,U,R>描述了参数类型为T,U,返回类型为R的函数。例如,可以将时间比较函数放入这个表达式中。

BiFunction<LocalDate, LocalDate, Boolean> timeComparator = 
    (t1, t2) -> t1.compareTo(t2) >= 0 ? true : false;

ArrayList有一个removeIf(Predicate)方法,Predicate<T>是一个函数式接口,其功能类似于条件表达式。在使用removeIf函数时可以直接传入一个lambda表达式。

list.removeIf(e -> e == null);

Supplier<T>也是一个非常有用的函数式接口,它的抽象方法没有参数,调用时生成一个T类型的值,以实现懒计算

LocalDate day = LocalDate.now();
LocalDate hireDay =
	Objects.requireNonNullElse(day,LocalDate.of(1970,1,1));
LocalDate hireDay2 =
	Objects.requireNonNullElseGet(day,()->LocalDate.of(1970,1,1));

Objects.requireNonNullElseGet只有在需要时才会通过供应者构建对象,而不需要事先放入对象

6.2.2 方法引用


假设希望只要出现一个定时器事件就打印这个事件对象,可以调用如下代码:

var timer = new Timer(1000, event -> System.out.println(event));

如果直接把println方法传递到Timer的构造器就更好了,具体做法如下:

var timer = new Timer(1000, System.out::println)

表达式System.out::println表示一个方法引用,表示函数式接口的抽象方法要调用给定的方法。

如果想为字符串数组按字典序排序,不考虑大小写,可以采用如下方法:

Arrays.sort(strings, String::compareToIgnoreCase);

从上面的例子可以看出,要使用::分隔方法名与类名或者对象名,主要有三种情况:

  • object::instanceMethod
  • Class::instanceMethod
  • Class::staticMethod

第一种情况,方法引用等价于向方法传递参数的lambda表达式。对于System.out::println,对象是System.out,方法引用等价于x -> System.out.println(x)

第二种情况,第一个参数会成为该方法的隐式参数,其余参数为显式参数。例如String::compareToIgnoreCase等同于(x, y) -> x.compareToIgnoreCase(y)

第三种情况,所有的参数都加入到静态方法参数列表中去。例如Math::pow等价于(x, y) -> Math.pow(x, y)

除此之外,还有构造器函数引用,如下表所示:

方法引用 等价的lambda表达式 说明
Integer::new x -> new Integer(x) 这是一个构造器引用,参数传递到构造器当中
Integer[]::new n -> new Integer[n] 这是一个数组构造器引用

注意,只有lambda表达式的方法体只调用一个方法才能使用方法引用代替lambda表达式。方法引用不能独立存在,总是会转换为函数式接口的实例。

方法引用中也可以使用this和super,例如this::equals等同于x -> this.equals(x),super::instanceMethod将调用这个对象的超类方法。

6.2.3 一些限制


lambda表达式可以捕获外围变量,专业术语叫做闭包外围变量是指在lambda表达式外声明的变量。请看下面代码:

public static void repeatMessage(String text, int delay){
    ActionListener listener = event -> {
        System.out.println(text);
        Toolkit.getDefaultToolkit().beep();
    };
	new Timer(delay,listener).start();
}

这里的外围变量(也叫自由变量)是text,delay。lambda表达式最终会转为一个函数式对象,这个对象封装了自由变量。但是在lambda表达式中,只能引用值不会改变的变量。如果在函数体内或者lambda表达式内对外围变量做修改,将不被允许编译。但java.util.concurrent.atomic包中类的对象不在此限制中。

public static void repeatMessage(String text, int delay){
	delay++; 
    ActionListener listener = event -> {
        System.out.println(text);
        //错误,上面修改了外围变量
        System.out.println(delay);
        Toolkit.getDefaultToolkit().beep();
    };
	new Timer(delay,listener).start();
}

这里做出限制的原因是:如果在lambda表达式中修改外围变量,并发执行多个动作就会不安全。

lambda表达式声明的局部变量不能与外部函数体的局部变量重名。

Path first = Path.of("/usr/bin");
//错误,first重复出现
Comparator<String> comp = (first, second) -> first.length() - second.length();

在lambda表达式中使用this,是指创建这个lambda表达式的方法的this,没有任何变化。

6.2.4 常用的函数式接口


函数式接口 参数类型 返回类型 抽象方法名 描述 其他方法
Runnable void run 作为无参数或返回值的动作运行
Supplier<T> T get 提供一个T类型的值
Consumer<T> T void accept 处理一个T类型的值 andThen
BiConsumer<T> T, U void accept 处理T和U类型的值 andThen
Function<T, R> T R apply 有一个T类型参数的函数 andThen, compose, identity
BiFunction<T, U, R> T, U R apply 有T和U类型参数的函数 andThen
UnaryOperator<T> T T apply 类型T上的一元操作符 andThen, compose, identity
BinaryOperator<T> T, T T apply 类型T上的二元操作符 andThen, maxBy, minBy
Predicate<T> T boolean test 布尔值函数 and, or, negate, isEqual
BiPredicate<T> T, U boolean test 有两个参数的布尔值函数 and, or, negate

除此之外,还有34个基本类型int、long、double的特殊化接口,使用特殊化接口比使用通用接口更高效。

如果自己设计函数式接口,可以添加@FunctionalInterface注解进行标记

6.2.5 Comparator接口


Comparator接口包含很多方便的静态方法来创建比较器,这些方法可以用于lambda表达式或方法引用。在代码中使用它们显得更为清晰。

  • comparing方法可以快速创建一个指定规则的比较器,假设有一个Person对象数组,可以按名字对该数组进行排序
Arrays.sort(people, Comparator.comparing(Person::getName));

如果两个对象比较之后相同还可以比较其他字段,即将比较器与thenComparing方法串起来

Arrays.sort(people, Comparator.comparing(Person::getName).thenComparing(Person::getAge));

上面两个都是默认的比较方式,字符串按照字典序排序,数字按大小排序。如果想指定一种自定义排序方式,可以再传入一个lambda表达式。比如,按照名字的长短对Person数组排序。

Arrays.sort(people, Comparator.comparing(Person::getName, (s, t) -> Integer.compare(s.length(), t.length())));

另外,comparing和thenComparing有很多变体形式,可以避免int、long、double值的装箱

Arrays.sort(people, Comparator.comparingInt(p -> p.getName().length()));

如果要比较的键可以返回null,可能就要用到nullsFirst和nullsLast适配器,这些静态方法会修改现有的比较器,在处理null不会抛出异常,而是将null标记为小于或大于正常值。具体来说,nullsFirst会把为null的键排在前面,nulllsLast会把为null的键放在后面。这两个方法都需要传入一个用于比较非null字段的比较器。

//为避免啰嗦,这里使用了静态导入 import static java.util.Comparator.*;
Arrays.sort(people,comparing(Person::getName,nullsLast(reverseOrder())));
  • naturalOrder 默认比较器
  • reverseOrder 逆序比较器, naturalOrder().reversed()等同于reverseOrder()

6.3 内部类


内部类是定义在另一个类中的类,使用内部类主要有两个好处:

  • 内部类可以对同一个包中的其他类隐藏
  • 内部类中的方法可以访问外围类的数据,方法,包括私有的

内部类之所以可以访问外围类的数据,是因为内部类对象会有一个隐式引用,指向实例化这个对象的外围类对象。所以能够访问外围对象的全部状态。

在Java中,除了静态内部类,其他三种内部类(成员内部类,方法内部类,匿名内部类)都可以访问外围类的全部数据。

6.3.1 使用内部类访问外围类数据


下面程序演示了内部类方法外围类数据

public class PartnerInnerClass {
    private int n1 = 12;
    public String name = "kun";

    class Inner{
        private double salary = 50d;
        private int n1 = 666;
        public void say(){
            //内部类可以方法外围类数据
            System.out.println("n1=" + PartnerInnerClass.this.n1 + " name=" + name);
        }
    }

    Inner inner = new Inner();

    public Inner getInner(){
        return inner;
    }
}

class Exe{
    public static void main(String[] args) {
        PartnerInnerClass.Inner inner = new PartnerInnerClass().new Inner();
        inner.say();
    }
}

上面的代码并没有编写成员内部类的构造器,实际上,编译器在底层自动生成了Inner类的构造器。并且Inner类还持有一个外围类引用字段this$0,先暂时称为outer吧

public Inner(PartnerInnerClass obj){
	outer = obj;
}

拿到对象后就能访问内部类的公共数据,但私有数据不是不能直接访问吗?为什么内部类可以获取到外围类的私有数据?其实,编译器还在外围类生成了可访问的外围类私有字段的方法(access$0)。

public class Outer{
	private boolean status;
	static boolean access$0(Outer outer){
		return outer.status;
	}
}

还有一点值得注意的是,内部类是一种编译器现象,虚拟机并不知道类中还套了类。包含内部类的源代码编译后会生成两份(或者更多,取决于声明的内部类数量)字节码文件。内部类的实际名字为OuterClass$InnerClass

6.3.2 内部类特殊语法规则


  1. 普通类只能声明为公共可见或包可见,但成员内部类可以添加访问修饰符。方法内部类和匿名内部类不能添加访问修饰符。
  2. 访问外围类引用的语法:OuterClass.this,如果内部类与外围类具有相同的变量名或方法名就可以使用这种方式区分
  3. 在外围类外部使用内部类,语法为:OuterClass.InnerClass
  4. 成员、方法、匿名内部类的所有静态字段都必须为final,不允许有静态方法。
  5. 静态内部类可以有静态字段、方法。静态方法只能访问外围类的静态字段、方法。
  6. 习惯上把方法内部类和成员内部类统称为局部内部类。局部内部类对外部世界完全隐藏,其作用域被限定在声明这个局部类的块中。
  7. 在接口中声明的内部类自动是public static
  8. 局部内部类不仅可以访问外部类字段,还可以访问局部变量。但是局部变量必须是事实最终变量(一旦赋值绝不会改变)实际上,局部内部类内部复制了局部变量字段。
class OuterClass$InnerClass{
	//构造器方法签名,假设内部类获取了局部变量boolean
	OuterClass$InnerClass(OuterClass, boolean);
    //复制的局部变量
	final boolean val$status;
	final Outerclass this$0;
}

6.3.3 匿名内部类


匿名内部类是一种特殊的局部内部类,没有具体的类名,创建方法为new + 接口名/类名 + (超类构造器参数) + {},花括号内为重写超类的方法。

因为没有类名,所以匿名内部类不能有构造器,但可以有初始化代码块

6.3.4 静态内部类


如果只是为了将一个类隐藏在另外一个类的内部,不需要内部类有外围类的引用,就可以声明为静态内部类。

public class Outer{
	private static Inner{
        //允许定义静态方法/域,只允许使用外围类的静态成员/方法
		...
	}
}

6.3.5 内部类使用场景与技巧


内部类可以使代码的关系更为紧密且避免重名。Java集合类中一些类就使用了内部类,如Map使用了内部类Entry,LinkedList使用内部类Node。在程序的其他地方也可以声明这些内部类,不会造成干扰。

匿名内部类通常用于回调,现在可以用lambda表达式代替。

生成日志或调试消息时,通常希望包含当前类类名,如:

System.out.println("Something awful happened in " + getClass())

但这对于静态方法并不奏效,静态方法没有this,应该使用如下表达式

new Object(){}.getClass.getEnClosingClass()

这里获取了匿名内部类的外围类。

posted @ 2023-01-06 09:37  清巡  阅读(42)  评论(0编辑  收藏  举报