Groovy 中的比较器和排序

作者: Paul King
发布时间: 2022-07-21 03:51PM


Cher 这篇博客文章的灵感来自 Stuart Marks 在精彩的Collections Refuelled 演讲博客 中的比较器示例。该博客发布于 2017 年,重点介绍了 Java 8 和 9 中 Java Collections 库的改进,包括众多比较器改进。它现在已经 5 年历史了,但对于任何使用 Java Collections 库的人来说,仍然非常值得推荐。

与原始博客不同的是,我们将使用 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 方法,使其不显示空 last 名字。

在 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.asListList.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 中删除空 last 名字。稍后我们将介绍 @Sortable 注释。

首先,Groovy 的 spaceship 运算符 <⇒ 允许我们编写第一个 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 方法,该方法对列表的副本进行排序,而不会更改原始列表。因此,要创建按 first 名字排序的列表,我们可以使用以下代码

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,用于处理集合。我们可以使用 GQueries 来检查和排序我们的集合。以下是一个示例

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  |
+-------+-------+-----+

或者我们可以按 last 名字降序排序,然后按年龄排序

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 功能都可用,包括 spaceship 运算符、sorttoSorted 方法以及 @Sortable AST 转换。