MENU

Java笔记(三):接口与Lambda表达式

June 21, 2019 • Read: 95 • 技术笔记阅读设置

摘自《Java核心技术 卷一》第六章

接口

基本定义

接口不是类,而是对类的一组需求描述。

比如使用Arrays.sort对自定义对象数组进行排序,该对象所属的类必须实现了Comparable接口

接口中所有方法自动属于public,因此在实现类中不必提供关键字public

接口决不能含有实例域,提供实例域和方法实现应该由相应实现接口的类来完成

Java SE 8之后,接口中可以提供简单的方法实现,但是依旧不能引用实例域,因为接口没有实例域

简单例子:

// Class Employee
class Employee implements Comparable<Employee> {
  ...
  @Override
  public int compareTo(Employee other) {
    return Double.compare(this.salary, other.salary);
  }
}

// Interface Class Comparable<T> Example
public interface Comparable<T> {
    public int compareTo(T o);
}

实现的compareTo 也和 equals一样在继承中可能出现问题

比如有个Manager类继承自Employee类:

class Manager extends Employee {

  public Manager(String name, int age, double salary) {
    super(name, age, salary);
  }

  @Override
  public int compareTo(Employee other) {
    Manager otherManager = (Manager) other;
    return Double.compare(other.getSalary(), otherManager.getSalary());
  }
}

// Main
public static void main(String[] args) throws Exception {
  Employee staff = new Employee("jerry", 23, 10000);
  Manager manager = new Manager("Mark", 56, 5000000);
  System.out.println(staff.compareTo(manager));  // 正常

  System.out.println(manager.compareTo(staff));  // throw ClassCastException
}

所以这不符合对称规则。所以如果子类之间比较的含义不一样,那就属于不同类对象的非法比较

如果存在一种通用算法,它能够对两个不同的子类对象进行比较,应该在超类中提供compareTo方法并设置为final(这样子类就不能重写该方法)

特性

接口不是类 无法用new实例化

可以声明接口变量,接口变量只能引用实现了接口的对象

Comparable x;
x = new Employee(...); // 只能引用实现了接口类的对象

可以用instanceof检查特定对象是实现了某接口

if (anObject instanceof Comparable) {...}

接口也可以扩展(像继承一样),接口中没有实例域,但可以包含常量,常量会被自动设置成public static final类型

Java中每个类只能继承一个超类,但是可以实现多个接口,这样可以赋予类很大的灵活性。

比如希望自己设计的类拥有比较和克隆能力,只需要实现ComparableCloneable接口即可

class Employee implements Cloneable, Comparable {...}

接口和抽象类的区别

既然有抽象类了,为什么还要接口呢?

因为在Java中,只能继承一个类,但却可以实现多个接口。

Java设计者本身不支持多重继承。

静态方法

Java 8中允许在接口中添加静态方法,可以省去使用伴随类实现静态方法。

默认方法

(Java 8)可以为接口方法提供一个默认实现,必须用default修饰符标记这个方法

public interface Comparable<T> 
{
    default int compareTo(T other) {
        return 0;
    }
}

默认方法可以理解为接口可以实现某个方法,而不需要实现类去实现该方法。

为什么要添加"默认方法”这个特性呢?

因为在提出该特性之前,对于已经发布的版本,想要给接口添加新的方法且不影响已有的实现是不可能的(需要修改全部实现该接口的类)。

而默认方法的提出则解决了这个问题,原先实现该接口的类依旧能够正常编译,引进默认方法就是为了解决接口修改与现有的实现类不兼容。

默认方法的冲突问题

假如某个类的超类和需要实现的接口需要实现的两个接口中定义了相同的方法,会如何处理?

Java中的规则如下:

  • 超类优先:如果超类中提供了一个方法,则接口中同名且有相同参数的默认方法会忽略!(这就是"类优先")

    // 超类冲突案例
    class Student extends Person implements Named {...}
    
    // 此处生成Student类的对象调用getName方法,由于类优先原则,将调用Person类中的方法
  • 接口冲突:如果一个超接口提供了一个默认方法,另一个接口提供了同名且参数类型相同(无论是不是为默认方法)的方法,该类必须覆盖这个方法来解决冲突!(必须解决此处的二义性二义性)

    // 接口冲突案例
    // Student class
    public class Student implements Person, Named {
      //此处都要覆盖getName方法以解决冲突
      @Override
      public String getName() {
        return "A student's name";
      }
    }
    
    // Person class
    public interface Person {
        // 此处即使不是默认方法,Student类中依旧要解决二义性
      default String getName() {
        return "A person's name";
      }
    }
    
    // Named class
    public interface Named {
    
      default String getName() {
        return getClass().getName() + "_" + hashCode();
      }
    }

这里还会引出一个问题:不要使用默认方法去重新定义Object类中的某个方法(例如toStringequals)

因为由于类优先原则,定义这种默认方法肯定会被Object.toString和Object.equals覆盖!

接口案例

1. Comparator接口

我们可以为Arrays.sort(obj, compare)传入第二个参数,该参数为一个数组比较器,是实现了Comparator<T>接口的实例。

现在实现一个比较器,以长度为标准判断两个String的大小

class lengthComparator implements Comparator<String> {

  @Override
  public int compare(String first, String second) {
    return first.length() - second.length();
  }
}

我们在使用时,依然要创建一个lengthComparator类的对象,需要用它来调用compare方法

public static void main(String[] args) {
    // 此处还是要实例化lengthComparator类
    Comparator<String> comp = new lengthComparator();
    String[] str = {"aaa", "bbbaaa", "ccsw", "dddssw233"};
    Arrays.sort(str, comp); // Arrays.sort会自动调用实例
    for (String s : str) {
      System.out.print(s + " ");
    }
  }

2. 对象克隆(挺容易出错)

前面已经提过,在Java中,建立一个对象的副本,原变量和副本都是同一个对象的引用,任一变量的改变都会影响到另一个变量(原变量改变 =》 副本也发生改变)。

如果想要摆脱这一点,则需要使用clone方法。

Object下的clone方法其实会产生问题的,比如对象A中包含对其他对象S的引用,那么克隆后的对象B中也会存在对相同对象S的引用,他们之间还是会共享一些信息。

clone方法是Object类的一个protected方法

如果原对象和浅克隆对象共享的子对象是不可变的(如String),那么这种共享是安全的,不过通常遇到包含的子对象都是可变的。

所以在克隆时,对于每一个类,需要判断

  1. 默认的clone方法是否满足需求
  2. 是否可以在可变的子对象上调用clone来修补默认的clone方法
  3. 是否不该使用clone

重写clone时需要:

  1. 实现Cloneable接口
  2. 重新定义clone方法,并指定public

Cloneable接口是Java提供的一组标记接口之一。标记接口不包含任何方法,它唯一作用就是允许在类型查询中使用instanceof

我们来看一个实现Cloneable标记接口的例子:

class Employee implements Comparable<Employee>, Cloneable{

  private String name;
  private int age;
  private double salary;
  private Date hireDay;

  ...

  public Employee clone() throws CloneNotSupportedException {
    return (Employee) super.clone();
  }
}

这里只是将clone方法变成public,我们还要对其中的可变子对象hireDay调用clone方法

public Employee clone() throws CloneNotSupportedException {
  // 先调用Object的clone
  Employee cloned = (Employee) super.clone();
  // 克隆hireDay
  cloned.hireDay = (Date) hireDay.clone();
  return cloned;
}

如果一个对象调用了clone,但这个对象并没有实现Cloneable接口,Object.clone()会抛出一个CloneNotSupportedException,上面例子的Date类已经实现了Cloneable接口,故不会抛出异常。

所有数组类型都有一个public的clone方法,而不是protected,可以用这个方法建立新数组。

Lambda表达式

基本例子:

(String first, String second)->
{
  ...
}

lambda表达式即使没有参数,仍然要提供空括号:

()->{...}

如果可以推导出lambda表达式参数类型,则忽略其类型:

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

无需指定lambda表达式的返回类型,返回类型会自动由上下文推出

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

如果lambda只在某些分支有返回值,其他分支没有返回值,则是不合法的,如下:

(int x) -> {if (x >= 0) return 1;} // error!

函数式接口

对于只有一个抽象方法的接口,需要这种接口的对象时,就可以提供一个lambda表达式,这种接口称为函数式接口

最好是把lambda表达式看做一个函数而不是一个对象

如Arrays.sort的第二个参数需要实现一个Comparator接口,Comparator是只有一个方法的接口,所以可以提供一个lambda表达式:

Arrays.sort(planets, (first, second) -> first.length() - second.length());

java.util.function包中定义了许多非常通用的函数式接口,比如BiFunction<T,U,R>,它描述了参数类型为T和U而且返回类型为R的函数。可以将字符串比较的lambda表达式保存到这个类型的变量中:

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

但是这个有啥用呢?因为毕竟也不能放到Arrays.sort()中去...

我发现该接口有个apply方法,应该用于是运行该lambda函数的。

System.out.println(comp.apply("Tom", "Jerry"));  // -2

方法引用

我理解为将现成的方法传递到其他代码中的去运行。

比如表达式System.out::println是一个方法引用,它等价于lambda表达式x->System.out.println(x)

比如我想对字符串排序:

String[] employees = {"tom", "jerry", "ddd", "jack", "acow", "zccc"};
Arrays.sort(employees, String::compareToIgnoreCase);
// 输出[acow, ddd, jack, jerry, tom, zccc]

::操作符分隔<u>方法名</u>和<u>对象或类名</u>,有以下三种情况:

  1. Object::instanceMethod
  2. Class::staticMethod

第1和第2种很好理解,就是等价于提供方法参数的lambda表达式

  1. Class::instanceMethod

第三种情况,第一个参数会成为方法的目标,例如String::compareToIgnoreCase等价于(x, y)->x.compareToIgnoreCase(y)

可以在方法引用中使用this::instanceMethodsuper::instanceMethod

构造器引用

与方法引用类似,只不过方法名为new

假设我把一个字符串列表转换为一个Person数组:

public static <T> ArrayList<T> createArrayList(T... elements) {
    ArrayList<T> list = new ArrayList<T>();
    for (T element : elements) {
      list.add(element);
    }
    return list;
  }

public static void main(String[] args) {
  ArrayList<String> names = Main.createArrayList("tom", "jerry", "jack");
  Stream<Person> stream = names.stream().map(Person::new);  // 对ArrayList中每个元素都调用new
  List<Person> people = stream.collect(Collectors.toList());

  System.out.println(people);  // [tom, jerry, jack]
}

可以用数组类型建立构造器引用,例如int[]::new。它有一个数组长度的参数。x->new int[x]

数组构造器引用对克服构造泛型类型T的数组所产生的限制的时候很有用(不太理解??),表达式new T[n]会被改为new Object[n]。

连接上面那个例子,假设我们需要返回的是一个Person的数组,Stream接口有toArray方法,但是它仅仅会返回Object[]

Object[] people = stream.toArray();
Person[] people = stream.toArray();  // 报错,无法将Object[] -> Person[]

这时我们将Person[]::new传入toArray方法:

Person[] people = stream.toArray(Person[]::new);

toArray方法会调用这个构造器来得到(我理解为生成)一个正确的类型(此处为Person)的数组;

然后用stream重的数据流填充这新生成的数组并最终赋值给people变量。

lambda中变量的作用域

如果我们希望在一个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变量即被称为被lambda表达式捕获

在lambda中引用外部变量中,有以下几种是不合法的:

  1. 在lambda中改变变量,比如上面例子中执行text="World"; (理解为在并发的时候会产生许多问题),是不合法的
  2. 引用的变量可能在外部改变,是不合法的

lambda表达式中捕获的变量必须是实际上的最终变量(effectively final)

  1. 在lambda中声明与一个局部变量同名的参数是不合法的

lambda表达式中this,是指创建这个lambda表达式方法的this参数,可以理解为this在lambda中并无任何特殊之处,和出现在包含该lambda的方法的其他位置一样。

处理lambda表达式

前面总结了两点:

  • 如何生成lambda表达式
  • 如何将lambda表达式传递到需要一个函数式接口的方法中

接下来要学习如何编写方法处理传递过来的lambda

下面是一个简单例子:

repeat(10, () -> System.out.println("Hello World!"));

这里的repeat需要接受这个lambda,需要选择一个函数式接口,这里我们选择Runnable接口,该接口定义如下:

public interface Runnable {
    /**
     * When an object implementing interface <code>Runnable</code> is used
     * to create a thread, starting the thread causes the object's
     * <code>run</code> method to be called in that separately executing
     * thread.
     * <p>
     * The general contract of the method <code>run</code> is that it may
     * take any action whatsoever.
     *
     * @see     java.lang.Thread#run()
     */
    public abstract void run();
}

这个接口可以看出一般作为无参数或返回值的动作运行。

Java中常用的函数式接口如下表:

常用的函数式接口

我们定义repeat如下:

public static void repeat(int n, Runnable action) {
  for (int i = 0; i < n; ++i) action.run();
}

调用action.run()时就会执行传递进来的lambda表达式的主体

我们再来一个更复杂一些的例子:

public interface IntConsumer {
  // int类型的参数并返回void
  void accept(int value);
}

private static void repeat(int n, IntConsumer action) {
  for (int i = 0; i < n; ++i) {
    action.accept(i);
  }
}

public static void main(String[] args) {
  repeat(10, (i) -> System.out.println("Countdown: " + (9 - i)));
}

在上面的例子里我们定义了IntConsumer这个函数式接口,该接口有一个抽象方法accept()

下表罗列了基本类型的函数式接口:

基本类型的函数式接口

如果一个你自己设计的接口其中只有一个抽象方法,可以用@FunctionalInterface注解来标记这个接口,这样做的优点是:

  • 如果你无意中增加了另一个非抽象方法,编译器会产生错误消息
  • javadoc页会指出该接口是函数式接口

再谈Comparator

Comparator接口包含很多方便的静态方法来创建比较器,这些方法可以用于lambda或方法引用

静态comparing方法取一个键提取器函数,它将类型T映射为一个可比较的类型。

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

可以把比较器与thenComparing方法串起来

Arrays.sort(people, 
        Comparator.comparing(Person::getLastName)
    .thenComparing(Person::getFirstName));

这种方法有很多变体形式,可以为comparingthenComparing方法提取的键指定一个比较器,比较器完全可以是lambda表达式

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

comparingthenComparing都有一种变体形式,可以避免int、long或double值的装箱

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

如果键函数可以返回null,要用到nullsFirstnullsLast适配器,这些方法会修改现有的比较器,从而在遇到null时不会出现异常

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

nullsFirst方法需要一个比较器,我们可以自己建立,也可以用Comparator.<String>naturalOrder,它可以为任何实现了Comparable的类建立一个比较器

这里Person类没有实现Comparable接口,也可以用naturalOrder,我的理解是因为比较器比较的是签名的Person::getName,而这个返回的是String类型的数据,默认就是实现了Comparable接口,所以可以直接使用

Arrays.sort(peoples,
        Comparator.comparing(Person::getName,
            Comparator.nullsFirst(Comparator.naturalOrder())));

// 也可以默认逆序
 Arrays.sort(peoples,
        Comparator.comparing(Person::getName,
            Comparator.nullsFirst(Comparator.reverseOrder())));  // reverseOrder() == naturalOrder().reversed()

Comparator比较器接口和Comparable排序接口的区别:

链接

Last Modified: September 23, 2019
Archives QR Code
QR Code for this page
Tipping QR Code