跳至主要內容

lambda表达式

Akkiri...大约 22 分钟JavaSEJava

⏱️时间安排:6天
⏳开始时间:2023-01-26
⌛结束时间:2023-01-31

lambda 表达式

接下来,将会了解如何使用 lambda 表达式采用一种简洁的语法定义代码块,以及如何编写处理 lambda 表达式的代码。

为什么引入 lambda 表达式

lambda 表达式是一个可传递的代码块,可以在以后执行一次或多次。在具体介绍语法之前,先观察一下我们在 Java 中的哪些地方用过这种代码块。

在前面,我们已经了解了如何按指定的时间间隔完成工作。将这个工作放在一个 ActionListener 的 actionPerformed 方法中:

class Worker implements ActionListener{
    public void actionPerformed(ActionEvent event){
        ...
    }
}

想要反复执行这个代码时,可以构造 Worker 类的一个实例。然后把这个实例提交到一个 Timer 对象。

这里的重点是 actionPerformed 方法包含希望以后执行的代码。


或者可以考虑如何用一个定制比较器完成排序。如果想按长度而不是按默认的字典排序对字符串进行排序,可以向 sort 方法传入一个对象:

class LengthComparator implements Comparator<String>{
    public int compare(String first, String second){
        return first.length() - second.length();
    }
}
...
Arrays.sort(strings, new LengthComparator());

compare 方法不是立即调用。实际上,在数组完成排序之前,sort 方法会一直调用 compare 方法,只要元素的顺序不正确就会重新排列元素。将比较元素所需的代码段放在 sort 方法中,这个代码将与其余的排序逻辑集成。

这两个例子有一些共同点,都是将一个代码块传递到某个对象(一个定时器,或者一个 sort 方法)。这个代码块会在将来某个时间被调用。

到目前为止,在 Java 中传递一个代码段并不容易,我们不能直接传递代码段。Java 是一种面向对象的语言,所以必须构造一个对象,这个对象的类需要有一个方法包含所需的代码。

在其他语言中,可以直接处理代码块。Java 设计者很长时间以来一直拒绝增加这个特性。毕竟,Java 的强大之处就在于其简单性和一致性。倘若只要一个特性能够让代码稍简洁一些,就把这个特性增加到语言中,那么这个语言很快就会变得一团糟,无法管理。不过,在另外那些语言中,并不只是创建线程或注册按钮点击事件的处理器更容易;它们的大部分 API 都更简单、更一致而且更强大。在 Java 中,也可以编写类似的 API 处理实现了某个特定接口的类对象,不过这种 API 使用可能很不方便 。

就现在来说,问题已经不是是否增强 Java 来支持函数式编程 , 而是要如何做到这一点 。设计者们做了多年的尝试 , 终于找到一种适合 Java 的设计。下一节中,我们会了解在 Java 中如何处理代码块

lambda 表达式的语法

再来考虑上一节讨论的排序例子。我们传入代码来检查一个字符串是否比另一个字符串短。这里要计算:

first.length() - second.length()

由于 Java 是一种强类型语言,所以我们还需要指定参数 first 和 second 的类型:

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

这就是一个 lambda 表达式。lambda 表达式就是一个代码块,以及必须传入代码的变量规范。

为什么起这个名字呢?

很多年前,那时还没有计算机,逻辑学家 Alonzo Church 想要形式化地表示能有效计算的数学函数。他使用了希腊字母 lambda(λ)来标记参数。如果他知道 Java API,可能就会写为:

λfirst.λsecond.first.length() - second.length()

从那以后,带有参数变量的表达式就被称为 lambda 表达式。

你已经见过 Java 中的一种 lambda 表达式:参数,箭头(->)以及一个表达式。如果代码要完成的计算无法放在一个表达式中,就可以像写方法一样,把这些代码放在 {} 中,并包含显示的 return 语句。例如:

(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);
    }
}

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

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

在这里,编译器可以推导出 first 和 second 必然是字符串,因为这个 lambda 表达式将赋给一个字符串比较器。

如果方法只有一个参数,而且这个参数的类型可以推导得出,那么甚至还可以省略小括号:

ActionListener listener = event ->
    System.out.println("The time is" + Instant.ofEpochMilli(event.getWhen()));

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

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

可以在需要 int 类型结果的上下文中使用。

警告

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

函数式接口

前面已经讨论过,Java 中有很多封装代码块的接口,如 ActionListener 或 Comparator。lambda 表达式与这些接口是兼容的。

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

为了展示如何转换为函数式接口,下面考虑 Arrays.sort 方法。它的第二个参数需要一个 Comparator 实例,Comparator 就是只有一个方法的接口,所以可以提供一个 lambda 表达式:

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

在底层,Arrays.sort 方法会接收实现了 Comparator<String> 的某个类的对象。在这个对象上调用 compare 方法会执行这个 lambda 表达式的体。这些对象和类的管理完全取决于具体实现,与使用传统的内联类相比,这样可能要高效得多。最好把 lambda 表达式看作是一个函数,而不是一个对象,另外要接受 lambda 表达式可以传递到函数式接口。

lambda 表达式可以转换为接口,这一点让 lambda 表达式很有吸引力。具体得语法很简短。下面再看一个例子:

var timer = new Timer(1000, event ->
	{
        System.out.println("At the tone, the time is "
                          + Instant.ofEpochMilli(event.getWhen()));
        Toolkit.getDefaultToolkit().beep();                   
	})

与使用实现了 ActionListener 接口的类相比,这段代码的可读性要好很多。

实际上,在 Java 中,对 lambda 表达式所能做到的也只是转换为函数式接口。在其他支持函数字面量的程序设计语言中,可以声明函数类型(如 (String, String) -> int),声明这些类型的变量,还可以使用变量保存函数表达式。不过,Java 设计者还是决定保持我们熟悉的接口概念,没有为 Java 语言增加函数类型。

Java API 在 java.util.function 包中定义了很多非常通用的函数式接口。其中一个接口 BiFunction<T, U, R> 描述了参数类型为 T 和 U 而返回类型为 R 的函数。可以把我们的字符串比较 lambda 表达式保存在这个类型的变量中。

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

不过,这对于排序并没有帮助。没有哪个 Arrays.sort 方法想要接收一个 BiFunction。如果你之前用过某种函数式程序设计语言,可能会认为这很奇怪。不过,对于 Java 程序员而言这非常自然。类似 Comparator 的接口往往有一个特定的用途,而不只是提供一个有指定参数和返回类型的方法。想要用 lambda 表达式做某些处理,还是要谨记表达式的用途,为它建立一个特定的函数式接口。

  • java.util.function 包中有一个尤其有用的接口 Predicate:
public interface Predicate<T>{
    boolean test(T t);
    //另外的默认方法或静态方法
}

ArraysList 类有一个 removeIf 方法,它的参数就是一个 Predicate。这个接口专门用来传递 lambda 表达式。例如,下面的语句将从一个数组列表删除所有的 null 值。

list.removeIf(e -> e == null);
  • 另一个有用的函数式接口是 Supplier<T>:
public interface Supplier<T>{
    T.get();
}

供应者(supplier)没有参数,调用时会生成一个 T 类型的值。供应者用于实现懒计算。例如,考虑以下调用:

LocalDate hireDay = Objects.requireNonNullOrElseGet(day, new LocalDate(1970, 1, 1));

这不是最优的。我们预计 day 很少为 null,所以希望只在必要时才构造默认的 LocalDate。通过使用供应者,我们就能延迟这个计算:

LocalDate hireDay = Objects.requireNonNullOrElseGet(day() -> new LocalDate(1970, 1, 1));

requireNonNullOrElseGet 方法只在需要值时才调用供应者。

方法引用

有时,lambda 表达式涉及一个方法。例如,假设你希望只要出现一个定时器事件就打印这个事件对象。当然,为此可以调用:

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

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

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

表达式 System.out::println 就是一个 方法引用(method reference),它指示编译器生成一个函数式接口的实例,覆盖这个接口的抽象方法来调用给定的方法。在这个例子中,会生成一个 ActionListener,它的 actionPerformed(ActionEvent e) 方法要调用 System.out.println(e)

提示

类似于 lambda 表达式,方法引用也不是一个对象。不过,当为一个类型为函数式接口的变量赋值时会生成一个对象。

提示

PrintStream 类(System.out 就是 PrintStream 类的一个实例)中有 10 个重载的 println 方法。编译器需要根据上下文确定使用哪一个方法。在我们的例子中,方法引用 System.out::println 必须转化为一个包含以下方法的 ActionListener 实例:

void actionPerformed(ActionEvent e)

这样会从 10 个重载的 println 方法中选出 println(Object x) 方法,因为 Object 与 ActionEvent 最匹配。调用 actionPerformed 方法时,就会打印这个事件对象。

现在假设我们把同样的这个方法引用赋给一个不同的函数式接口:

Runnable task = System.out::println;

这个 Runnable 函数式接口有一个无参的抽象方法:

void run()

在这里,会选择无参数的 println() 方法。调用 task.run() 会向 System.out 打印一个空行。


再来看一个例子,假设你想对字符串进行排序,而不考虑字母的大小写。可以传递以下方法表达式:

Arrays.sort(String, String::compareToIgnoreCase)

从这些例子可以看出,要用 :: 运算符分割方法名与对象或类名。主要有 3 种情况:

  1. object::instanceMethod
  2. Class::instanceMethod
  3. Class::staticMethod

在第 1 种情况下,方法引用等价于向方法传递参数的 lambda 表达式。对于 System.out::println,对象是 System.out,所以方法表达式等价于 x -> System.out.println(x)
对于第 2 种情况,第 1 个参数会成为方法的隐式参数。例如,String::compareToIgnoreCase 等同于 (x, y) -> x.compareToIgnoreCase(y)
在第 3 种情况下,所有参数都传递到静态方法:Math::pow 等价于 (x, y) -> Math.pow(x, y)

注意

只有当 lambda 表达式的体只调用一个方法而不做其他操作时,才能把 lambda 表达式重写为方法引用。考虑以下 lambda 表达式:

s -> s.length() == 0

这里有一个方法调用。但是还有一个比较,所以这里不能使用方法引用。

提示

如果有多个同名的重载方法,编译器就会尝试从上下文中找出你指的是哪一个方法。例如, Math.max 方法有两个版本,一个用于整数,另一个用于 double 值。选择哪一个版本取决于 Math::max 转换为哪个函数式接口的方法参数 类似于 lambda 表达式,方法引用不能独立存在,总是会转换为函数式接口的实例。

提示

有时 API 包含一些专门用作方法引用的方法。例如,Objects 类有一个方法 isNull,用于测试一个对象引用是否为 null。乍看上去这好像没有什么用,因为测试 obj == nullObjects.isNull(obj) 更有可读性。不过可以把方法引用传递到任何有 Predicate 参数的方法。例如,要从一个列表删除所有 null 引用,就可以调用:

list.removeIf(Objects::isNull);
// A bit easier to read than list.removelf(e -> e == null);

可以在方法引用中使用 this 参数。例如,this::equals 等同于 x -> this.equals(x)。使用 super 也是合法的。下面的方法表达式:

super::instanceMethod

使用 this 作为目标,会调用给定方法的超类版本。

为了展示这一点,下面给出一个假像的例子:

class Greeter{
    public void greet(ActionEvent event){
        System.out.println("Hello, the time is "
                          + Instant.ofEpochMilli(event.getWhen()));
    }
}

class RepeatedGreeter extends Greeter{
    public void greet(ActionEvent event){
        var timer = new Timer(1000, super::greet);
        timer.start();
    }
}

RepeatedGreeter.greet 方法开始执行时,会构造一个 Timer,每次定时器滴答时会执行 super::greet 方法

构造器引用拓展知识

构造器引用与方法引用类似,只不过方法名为 new。例如,Person::new 是 Person 构造器的一个引用。具体是哪一个构造器,这取决于上下文。假设有一个字符串列表。可以把它转换为一个 Person 对象数组,为此要在各个字符串上调用构造器,调用如下:

ArrayList<String> names = ...;
Stream<Person> stream = names.stream().map(Person::new);
List<Person> people = stream.collect(Collectors.toList());

stream、map 和 collect 方法的详细内容将在后面讨论。就现在来说。重点是 map 方法会为各个列表元素调用 Person(String) 构造器。如果有多个 Person 构造器,编译器会选择有一个 String 参数的构造器,因为它从上下文推导出这是在调用带一个字符串的构造器。

可以用数组类型建立构造器引用。例如,int[]::new 是一个构造器调用,它有一个参数:即数组的长度。这等价于 lambda 表达式 x -> new int[x]

Java 有一个限制,无法构造泛型类型为 T 的数组。数组构造器引用对于克服这个限制很有用。表达式 new T[n] 会产生错误,因为这会改为 new Object[n]。对于开发类库的人来说,这是一个问题。例如,假设我们需要一个 Person 对象数组、Stream 接口有一个 toArray 方法可以返回 Object 数组:

Object[] people = stream.toArray();

不过,这并不能让人满意。用户希望得到一个 Person 引用数组,而不是 Object 引用数组。流库利用构造器引用解决了这个问题。可以把 Person[]::new 传入 toArray 方法:

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

toArray 方法调用这个构造器来得到一个有正确类型的数组。然后填充并返回这个数组。

变量作用域

通常,你可以希望能够在 lambda 表达式中访问外围方法或类中的变量。考虑下面这个例子:

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

来看这样一个调用:

repeatMessage("Hello", 1000);

现在来看 lambda 表达式中的变量 text。注意这个变量并不是在这个 lambda 表达式中定义的。实际上,这是 repeatMessage 方法的一个参数变量。

不过,这里有一个不那么明显的问题。lambda 表达式的代码可能在 repeatMessage 调用返回很久以后才运行,而那时这个参数变量已经不存在了。如何保留 text 变量呢?

要了解到底会发生什么,下面来巩固以下我们对 lambda 表达式的理解。lambda 表达式有 3 个部分:

  1. 一个代码块;
  2. 参数;
  3. 自由变量的值,这是指非参数而且不在代码中定义的变量。

在我们的例子中,这个 lambda 表达式 有一个自由变量 text。表示 lambda 表达式的数据结构必须存储自由变量的值,在这里就是字符串 "Hello"。我们说它被 lambda 表达式捕获(captured)。(下面来看具体的实现细节。例如,可以把一个 lambda 表达式转换为包含一个方法的对象,这样自由变量的值就会复制到这个对象的实例变量中。)

提示

关于代码块以及自由变量值有一个术语:闭包(closure)。在 Java 中,lambda 表达式就是闭包。

可以看到,lambda 表达式可以捕获外围作用域中变量的值,在 Java 中,要确保所捕获的值是明确定义的,这里有一个重要的限制。在 lambda 表达式中,只能引用值不会改变的变量。例如,下面的做法是不合法的:

public static void countDown(int start, int delay){
    ActionListener listener = event ->
    {
        start--; // 错误:不能改变被捕获的变量
        System.out.println(start);
    };
    new Timer(delay, listener).start();
}

这个限制是有原因的。如果在 lambda 表达式中更改变量,并发执行多个动作时就会不安全。对于目前为止我们看到的动作不会发生这种情况,不过一般来讲,这确实是一个严重的问题。

另外,如果在 lambda 表达式中引用一个变量,而这个变量可能再外部改变,这也是不合法的。例如,下面就是不合法的:

public static void repeat(String text, int count){
    for(int i = 1; i <= count; i++){
        ActionListener listener = event -> 
        {
            System.out.println(i + ":" + text);
            // 错误:不能引用在改变的变量 i
        }
        new Timer(1000, listener).start();
    }
}

这里有一条规则:lambda 表达式中捕获的变量必须实际上是事实最终变量(effectively final)。事实最终变量是指,这个变量初始化之后就不会再为它赋新值。在这里,text 总是指示同一个 String 对象,所以捕获这个变量是合法的。不过,i 的值会改变,因此不能捕获 i。

lambda 表达式的体与嵌套块有相同的作用域。这里同样适用命名冲突和遮蔽的有关规则。在 lambda 表达式中声明与一个局部变量同名的参数或局部变量是不合法的。

Path first = Path.of("/usr/bin");
Comparator<String> comp
    = (first, second) - first.length() - second.length();
	// 错误:变量 first 已经被定义了

在一个方法中,不能有两个同名的局部变量,因此,lambda 表达式中同样也不能有同名的局部变量。

在一个 lambda 表达式中使用 this 关键字时,是指创建这个 lambda 表达式的方法的 this 参数。例如,考虑下面的代码:

public class Application{
    public void init(){
        ActionListener listener = event ->
        {
            System.out.println(this.toString());
            ...
        }
        ...
    }
}

表达式 this.toString() 会调用Application 对象的 toString 方法,而不是 ActionListener 实例的方法。在 lambda 表达式中,this 的使用并没有任何特殊之处。lambda 表达式的作用域嵌套在 init 方法中,与出现在这个方法的其他位置一样,lambda 表达式中 this 的含义并没有变化。

处理 lambda 表达式

前面我们已经了解了如何生成 lambda 表达式,以及如何把 lambda 表达式传递到需要一个函数式接口的方法。下面来看如何编写方法处理 lambda 表达式。

使用 lambda 表达式的重点是 延迟执行(deferred execution)。毕竟,如果想要立即执行代码,完全可以直接执行,而无需把它包装在一个 lambda 表达式中。之所以希望以后再执行代码,这有很多原因,如:

  • 在一个单独的线程中运行代码;
  • 多次运行代码;
  • 在算法的适当位置运行代码(例如,排序中的比较操作);
  • 发生某种情况时执行代码(如,点击了一个按钮,数据到达,等等);
  • 只在必要时才运行代码。

下面来看一个简单的例子。假设你想要重复一个动作 n 次,将这个动作和重复次数传递到一个 repeat 方法:

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

要接受这个 lambda 表达式,需要选择(有时可能需要自己提供)一个函数式接口。下表列出了 Java API 中提供的最重要的函数式接口。在这里,我们可以使用 Runnable 接口:

public static void repeat(int n, Runnable action){
    for(int i = 0; i < n; i++) action.run();
}
函数式接口参数类型返回类型抽象方法名描述其他方法
Runnablevoidrun作为无参数或返回值的动作运行
Supplier<T>Tget提供一个 T 类型的值
Consumer<T>Tvoidaccept处理一个 T 类型的值andThen
BiConsumer<T, U>T, Uvoidaccept处理 T 和 U 类型的值andThen
Function<T, R>TRapply有一个 T 类型参数的函数compose, andThen, identity
BiFunction<T, U, R>T, URapply有 T 和 U 类型参数的函数andThen
UnaryOperator<T>TTapply类型 T 上的一元操作符compose, andThen, identity
BinaryOperator<T>T, TTapply类型 T 上的二元操作符andThen, maxBy, minBy
Predicate<T>Tbooleantest布尔值函数and, or, negate, isEqual
BiPredicate<T, U>T, Ubooleantest有两个参数的布尔值函数and, or, negate

需要说明,调用 action.run() 时会执行这个 lambda 表达式的主体。

现在让这个例子更复杂一些。我们希望告诉这个动作它出现在哪一次迭代中。为此,需要选择一个合适的函数式接口,其中要包含一些方法,这个方法有一个 int 参数而且返回类型为 void。处理 int 值得标准接口如下:

public interface IntConsumer{
    void accept(int value);
}

下面给出 repeat 方法得改进版本:

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

可以如下调用:

repeat(10, i -> System.out.println("Countdown: " + (9 - i)));

下表列出了基本类型 int、 long 和 double 的 34 个可用的特殊化接口。后面会了解到,使用这些特殊化接口比使用通用接口更高效。出于这个原因,上面的例子中使用了 IntConsumer 而不是 Consumer<Integer>

函数式接口参数类型返回类型抽象方法名
BooleanSupplierbooleangetAsBoolean
___P___SupplierpgetAs___P___
___P___Consumerpvoidaccept
Obj___P___Consuner<T>T, pvoidaccept
___P___Function<T>pTapply
___P___To___Q___FunctionpqapplyAs___Q___
To___P___Function<T>TpapplyAs___P___
To___P___BiFunction<T, U>T, UpapplyAs___P___
___P___UnaryOperatorppapplyAs___P___
___P___BinaryOperatorp, ppapplyAs___P___
___P___Predicatepbooleantest

注:p、q 是 int、long、double;P、Q 是 Integer、Long、Double

提示

如果设计你自己的接口,其中只有一个抽象方法,可以用 @FunctionalInterface 注解来标记这个接口。这样做有两个优点。如果你无意中增加了另一个抽象方法,编译器会产生一个错误信息。另外 javadoc 页里会指出你的接口是一个函数式接口。

并不是必须使用注解。根据定义,任何有一个抽象方法的接口都是函数式接口。不过使用 @FunctionalInterface 注解确实是一个好主意。

再谈 Comparator

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

静态 comparing 方法取一个 “键提取器” 函数,它将类型 T 映射为一个可比较的类型(如 String)。对要比较的对象应用这个函数,然后对返回的键完成比较。例如,假设有一个 Person 对象数组,可以如下按名字对这些对象进行排序:

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

与手动实现一个 Comparator 相比,这当然要容易得多。另外,代码也更为清晰,因为显然我们都希望按人名来比较。

可以把比较器与 thenComparing 方法串起来,来处理比较结果相同得情况。例如:

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

如果两人姓名相同,就会使用第二个比较器。

这些方法有很多变体形式。可以为 comparing 和 thenComparing 方法提取的键指定一个比较器。例如,可以如下根据人名长度完成排序:

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 值时不会抛出异常,而是将这个值标记为小于或大于正常值。例如,假设一个人没有中间名时 getMiddleName 会返回一个 null,就可以使用 Comparator.comparing(Person::getMiddleName(), Comparator.nullsFirst(...))

nullsFirst 方法需要一个比较器,在这里就是比较两个字符串的比较器。naturalOrder 方法可以为任何实现了 Comparable 的类建立一个比较器。在这里,Comparator.<String>naturalOrder() 正是我们需要的。下面是一个完整的调用,可以按可能为 null 的中间名。这里使用了一个静态导入 java.util.Comparator.*,使这个表达式更为简洁,更便于阅读。注意 naturalOrder 的类型可以推导得出。

Arrays.sort(people, comparing(Person::getMiddleName, nullsFirst(naturalOrder())));

静态 reverseOrder 方法会提供自然顺序得逆序。要让比较器逆序比较,可以使用 reversed 实例方法。例如 naturalOrder().reversed() 等同于 reverseOrder()

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.5