Groovy™ 中的比较器和排序

作者: Paul King

发布时间:2022-07-21 03:51 PM


Cher 这篇博客文章的灵感来源于 Stuart Marks 精彩的 Collections Refuelled 演讲博客中关于 Comparator 的例子。那篇博客写于 2017 年,重点介绍了 Java 8 和 9 中 Java Collections 库的改进,包括大量的 Comparator 改进。虽然现在已经有 5 年历史了,但对于任何使用 Java Collections 库的人来说,仍然强烈推荐。

与原始博客示例中的 Student 类不同,我们将使用一个 Celebrity 类(以及后来的记录),它具有相同的 firstlast 名字段以及一个额外的 age 字段。我们最初将按 last 姓氏进行比较,空值优先于非空值,然后按 first 姓氏,最后按 age 进行比较。

与原始博客一样,我们将处理空值,例如,只知道一个名字的名人。

Java 比较器回顾

Java logo 如果用 Java 编写,我们的 Celebrity 类看起来像这样

public class Celebrity {                    // Java
    private String firstName;
    private String lastName;
    private int age;

    public Celebrity(String firstName, int age) {
        this(firstName, null, age);
    }

    public Celebrity(String firstName, String lastName, int age) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.age = age;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }

    public String getLastName() {
        return lastName;
    }

    public void setLastName(String lastName) {
        this.lastName = lastName;
    }

    @Override
    public String toString() {
        return "Celebrity{" +
                "firstName='" + firstName +
                (lastName == null ? "" : "', lastName='" + lastName) +
                "', age=" + age +
                '}';
    }
}

如果作为 Java 记录(JDK16+)会看起来好得多,但我们暂时将遵循原始博客示例的精神。这是相当标准的样板代码,实际上大部分是由 IntelliJ IDEA 生成的。唯一稍微有趣的地方是我们调整了 toString 方法,使其不显示空的姓氏。

在 JDK 8 中,使用旧式比较器编码,创建和排序一些名人的主应用程序可能看起来像这样

import java.util.ArrayList;            // Java
import java.util.Collections;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<Celebrity> celebrities = new ArrayList<>();
        celebrities.add(new Celebrity("Cher", "Wang", 63));
        celebrities.add(new Celebrity("Cher", "Lloyd", 28));
        celebrities.add(new Celebrity("Alex", "Lloyd", 47));
        celebrities.add(new Celebrity("Alex", "Lloyd", 37));
        celebrities.add(new Celebrity("Cher", 76));
        Collections.sort(celebrities, (c1, c2) -> {
            String f1 = c1.getLastName();
            String f2 = c2.getLastName();
            int r1;
            if (f1 == null) {
                r1 = f2 == null ? 0 : -1;
            } else {
                r1 = f2 == null ? 1 : f1.compareTo(f2);
            }
            if (r1 != 0) {
                return r1;
            }
            int r2 = c1.getFirstName().compareTo(c2.getFirstName());
            if (r2 != 0) {
                return r2;
            }
            return Integer.compare(c1.getAge(), c2.getAge());
        });
        System.out.println("Celebrities:");
        celebrities.forEach(System.out::println);
    }
}

当我们运行这个例子时,输出看起来像这样

Celebrities:
Celebrity{firstName='Cher', age=76}
Celebrity{firstName='Alex', lastName='Lloyd', age=37}
Celebrity{firstName='Alex', lastName='Lloyd', age=47}
Celebrity{firstName='Cher', lastName='Lloyd', age=28}
Celebrity{firstName='Cher', lastName='Wang', age=63}

正如原始博客中所指出的那样,这段代码相当繁琐且容易出错,通过 JDK8 中的比较器改进可以大大改善。

import java.util.Arrays;             // Java
import java.util.List;

import static java.util.Comparator.comparing;
import static java.util.Comparator.naturalOrder;
import static java.util.Comparator.nullsFirst;

public class Main {
    public static void main(String[] args) {
        List<Celebrity> celebrities = Arrays.asList(
                new Celebrity("Cher", "Wang", 63),
                new Celebrity("Cher", "Lloyd", 28),
                new Celebrity("Alex", "Lloyd", 47),
                new Celebrity("Alex", "Lloyd", 37),
                new Celebrity("Cher", 76));
        celebrities.sort(comparing(Celebrity::getLastName, nullsFirst(naturalOrder())).
                thenComparing(Celebrity::getFirstName).thenComparing(Celebrity::getAge));
        System.out.println("Celebrities:");
        celebrities.forEach(System.out::println);
    }
}

原始博客还指出了 JDK9 中用于列表创建的便捷工厂方法,您可能会在这里考虑使用它们。对于我们的情况,我们将进行就地排序,因此这些方法返回的不可变列表在这里对我们没有帮助,但是 Arrays.asList 并不比 List.of 长多少,并且非常适用于此示例。

除了更短之外,comparingthenComparing 方法以及内置的比较器(如 nullsFirstnaturalOrdering)允许更简单的组合性。数组列表中的排序也比早期 JDK 上与 Collections.sort 方法一起使用的排序更有效。运行示例时的输出与之前相同。

Groovy 比较器介绍

Groovy logo 大约在 Java 发展其比较器机制的同时,Groovy 也添加了一些补充功能来解决许多相同的问题。我们将看看其中一些功能,以及如何根据需要使用我们上面看到的 JDK 改进功能。

首先,让我们创建一个 Groovy Celebrity 记录

@Sortable(includes = 'last,first,age')
@ToString(ignoreNulls = true, includeNames = true)
record Celebrity(String first, String last = null, int age) {}

并创建我们的名人列表

var celebrities = [
    new Celebrity("Cher", "Wang", 63),
    new Celebrity("Cher", "Lloyd", 28),
    new Celebrity("Alex", "Lloyd", 47),
    new Celebrity("Alex", "Lloyd", 37),
    new Celebrity(first: "Cher", age: 76)
]

记录定义简洁明了。它在最近的 Java 版本中也会表现出色。Groovy 解决方案的一个优点是它可以在早期 JDK 上提供模拟记录,并且还具有一些很好的声明性转换来调整记录定义。我们可以省略 @ToString 注解,这样我们会得到一个标准的记录风格的 toString。或者我们可以在记录定义中添加一个 toString 方法,类似于 Java 示例中所做的。使用 @ToString 允许我们以声明方式从 toString 中删除空的姓氏。我们稍后会介绍 @Sortable 注解。

首先,Groovy 的飞船操作符 <⇒ 允许我们编写第一个 Java 版本中“繁琐”代码的简洁版本。它看起来像这样

celebrities.sort { c1, c2 ->
    c1.last <=> c2.last ?: c1.first <=> c2.first ?: c1.age <=> c2.age
}
println 'Celebrities:\n' + celebrities.join('\n')

输出看起来像这样

Celebrities:
Celebrity(first:Cher, age:76)
Celebrity(first:Alex, last:Lloyd, age:37)
Celebrity(first:Alex, last:Lloyd, age:47)
Celebrity(first:Cher, last:Lloyd, age:28)
Celebrity(first:Cher, last:Wang, age:63)

如果我们需要空值排在后面,我们还需要做一点点额外的工作,但是默认值对于手头的示例来说效果很好。

我们还可以利用前面提到的“JDK8 新功能”方法

celebrities.sort(comparing(Celebrity::last, nullsFirst(naturalOrder())).
        thenComparing(c -> c.first).thenComparing(c -> c.age))

但这就是我们应该回头进一步解释 @Sortable 注解的地方。该注解与抽象语法树 (AST) 转换(简称转换)相关联,它为我们提供了一个自动的 compareTo 方法,该方法考虑了记录的属性(如果它是类,也同样如此)。由于我们提供了 includes 注解属性并提供了一系列属性名称,这些名称的顺序决定了比较器中使用的属性的优先级。我们同样可以只包含该列表中的某些名称,或者提供一个 excludes 注解属性并仅提及我们不想包含的属性。

它还将 Comparable<Celebrity> 添加到我们记录实现的接口列表中。那么,这一切意味着什么呢?这意味着我们只需编写

celebrities.sort()

@Sortable 注解相关联的转换也为我们提供了一些额外的比较器。为了按年龄排序,我们可以使用其中一个比较器

celebrities.sort(Celebrity.comparatorByAge())

这给出了此输出

Celebrities:
Celebrity(first:Cher, last:Lloyd, age:28)
Celebrity(first:Alex, last:Lloyd, age:37)
Celebrity(first:Alex, last:Lloyd, age:47)
Celebrity(first:Cher, last:Wang, age:63)
Celebrity(first:Cher, age:76)

除了 sort 方法,Groovy 还提供了一个 toSorted 方法,它对列表的副本进行排序,而保持原始列表不变。因此,要创建一个按名字排序的列表,我们可以使用以下代码

var celebritiesByFirst = celebrities.toSorted(Celebrity.comparatorByFirst())

如果以与以前示例类似的方式输出,则会得到

Celebrities:
Celebrity(first:Alex, last:Lloyd, age:37)
Celebrity(first:Alex, last:Lloyd, age:47)
Celebrity(first:Cher, last:Lloyd, age:28)
Celebrity(first:Cher, last:Wang, age:63)
Celebrity(first:Cher, age:76)

如果您是函数式编程的爱好者,您可以考虑使用 List.of 来定义原始列表,然后只在后续处理中使用 toSorted 方法调用。

融入一些语言集成查询

Groovy 还具有 GQuery(又名 GINQ)功能,它提供了一种 SQL 风格的 DSL,用于处理集合。我们可以使用 GQuery 来检查和排序我们的集合。这是一个例子

println GQ {
    from c in celebrities
    select c.first, c.last, c.age
}

其输出如下

+-------+-------+-----+
| first | last  | age |
+-------+-------+-----+
| Cher  |       | 76  |
| Alex  | Lloyd | 37  |
| Alex  | Lloyd | 47  |
| Cher  | Lloyd | 28  |
| Cher  | Wang  | 63  |
+-------+-------+-----+

在这种情况下,它使用的是 @Sortable 给我们的自然排序。

或者我们可以按年龄排序

println GQ {
    from c in celebrities
    orderby c.age
    select c.first, c.last, c.age
}

其输出如下

+-------+-------+-----+
| first | last  | age |
+-------+-------+-----+
| Cher  | Lloyd | 28  |
| Alex  | Lloyd | 37  |
| Alex  | Lloyd | 47  |
| Cher  | Wang  | 63  |
| Cher  |       | 76  |
+-------+-------+-----+

或者我们可以按姓氏降序,然后按年龄排序

println GQ {
    from c in celebrities
    orderby c.last in desc, c.age
    select c.first, c.last, c.age
}

其输出如下

+-------+-------+-----+
| first | last  | age |
+-------+-------+-----+
| Cher  | Wang  | 63  |
| Cher  | Lloyd | 28  |
| Alex  | Lloyd | 37  |
| Alex  | Lloyd | 47  |
| Cher  |       | 76  |
+-------+-------+-----+

结论

我们已经看到了在 Groovy 中使用比较器的小例子。所有强大的 JDK 功能都可以使用,以及飞船操作符、sorttoSorted 方法以及 @Sortable AST 转换。