如何简化一个空安全的compareTo()实现?

195

我正在为一个简单的类实现compareTo()方法(以便能够使用Java平台提供的Collections.sort()和其他好用的东西):

public class Metadata implements Comparable<Metadata> {
    private String name;
    private String value;

// Imagine basic constructor and accessors here
// Irrelevant parts omitted
}

我希望这些对象的{{自然排序}}是:1)按名称排序;2)如果名称相同,则按值排序。这两种比较都应该不区分大小写。对于这两个字段,null值是完全可以接受的,所以在这些情况下,{{compareTo}}不能中断。
我想到的解决方案如下(我在这里使用“守卫条款”,而其他人可能更喜欢单一返回点,但这并不重要):
// primarily by name, secondarily by value; null-safe; case-insensitive
public int compareTo(Metadata other) {
    if (this.name == null && other.name != null){
        return -1;
    }
    else if (this.name != null && other.name == null){
        return 1;
    }
    else if (this.name != null && other.name != null) {
        int result = this.name.compareToIgnoreCase(other.name);
        if (result != 0){
            return result;
        }
    }

    if (this.value == null) {
        return other.value == null ? 0 : -1;
    }
    if (other.value == null){
        return 1;
    }

    return this.value.compareToIgnoreCase(other.value);
}

这段代码可以完成任务,但我对它并不完全满意。诚然,它并不是非常复杂,但相当冗长和乏味。
问题是,你如何使其更简洁(同时保留功能)?如果有必要,可以参考Java标准库或Apache Commons。是否唯一的简化选项是实现自己的“NullSafeStringComparator”,并将其应用于比较两个字段?
编辑1-3:Eddie是正确的;已修复上面的“两个名称都为空”的情况
关于被接受的答案
我在2009年提出了这个问题,在当时的Java 1.6上,当时Eddie的纯JDK解决方案是我首选的被接受的答案。直到现在(2017年),我才有机会更改它。
还有第三方库解决方案——我曾经喜欢的2009年Apache Commons Collections和2013年Guava解决方案,现在我将干净的Lukasz Wiktor的Java 8解决方案作为被接受的答案。如果使用Java 8,则应该优先考虑该解决方案,现在Java 8应该可用于几乎所有项目。

https://dev59.com/IUbRa4cB1Zd3GeqPwQOn - Ciro Santilli OurBigBook.com
18个回答

244
你可以简单地使用Apache Commons Lang
result = ObjectUtils.compare(firstComparable, secondComparable)

6
(@Kong:这解决了空安全问题,但没有解决原问题中提到的大小写不敏感性。因此不会更改已接受的答案。)这个解决方案解决了空值安全问题,但没有考虑原问题中提到的大小写不敏感性。因此并不会更改已经被接受的答案。 - Jonik
3
在我看来,即使有一些子项目比其他项目维护得更好,Apache Commons 在2013年不应该成为被接受的答案。(可以使用Guava来实现相同的功能)请查看nullsFirst()/nullsLast() - Jonik
10
为什么您认为在2013年Apache Commons不应该成为被接受的答案? - reallynice
3
Apache Commons的很多部分都是过时/维护不良/质量低劣的东西。对于它提供的大多数内容,都有更好的替代品,例如在Guava中就提供了非常高质量的库,在JDK本身也越来越多。大约在2005年,Apache Commons很棒,但现在大多数项目都不需要它了。(当然,也有例外;例如,如果我出于某种原因需要FTP客户端,我可能会使用Apache Commons Net中的一个等等。) - Jonik
8
@Jonik,你会如何使用Guava回答这个问题?你声称Apache Commons Lang(包org.apache.commons.lang3)是“遗留/维护不善/低质量”是错误的或者至少是毫无根据的。Commons Lang3易于理解和使用,而且它正在积极维护。除了Spring Framework和Spring Security之外,它可能是我最常用的库——例如,具有其空值安全方法的StringUtils类使得输入规范化成为微不足道的工作。 - Paul
显示剩余5条评论

224

使用 Java 8:

private static Comparator<String> nullSafeStringComparator = Comparator
        .nullsFirst(String::compareToIgnoreCase); 

private static Comparator<Metadata> metadataComparator = Comparator
        .comparing(Metadata::getName, nullSafeStringComparator)
        .thenComparing(Metadata::getValue, nullSafeStringComparator);

public int compareTo(Metadata that) {
    return metadataComparator.compare(this, that);
}

12
我支持使用Java 8内置功能代替Apache Commons Lang,但Java 8的代码相当丑陋,而且很冗长。目前我会坚持使用org.apache.commons.lang3.builder.CompareToBuilder。 - jschreiner
2
这对于Collections.sort(Arrays.asList(null, val1, null, val2, null))无效,因为它将尝试在空对象上调用compareTo()。老实说,这似乎是集合框架的问题,我正在努力找出如何解决这个问题。 - Pedro Borges
3
作者询问如何对具有可排序字段的容器对象进行排序(其中这些字段可能为空),而不是对空容器引用进行排序。因此,尽管您的评论是正确的,即当List包含null时,Collections.sort(List)无法工作,但该评论与问题无关。 - Scrubbie
2
@PedroBorges null值没有自然顺序。如果您想对包含null的列表或数组进行排序,必须使用Comparator - Holger

96

我会实现一个空安全比较器。也许已经有人实现了这个功能,但是这很简单,所以我总是自己实现。

注意:你上面的比较器,如果两个名称都为null,则不会比较值字段。 我认为这不是你想要的。

我会使用以下代码来实现:

// primarily by name, secondarily by value; null-safe; case-insensitive
public int compareTo(final Metadata other) {

    if (other == null) {
        throw new NullPointerException();
    }

    int result = nullSafeStringComparator(this.name, other.name);
    if (result != 0) {
        return result;
    }

    return nullSafeStringComparator(this.value, other.value);
}

public static int nullSafeStringComparator(final String one, final String two) {
    if (one == null ^ two == null) {
        return (one == null) ? -1 : 1;
    }

    if (one == null && two == null) {
        return 0;
    }

    return one.compareToIgnoreCase(two);
}

编辑:已修复代码样本中的拼写错误。这就是我先不测试它的结果!

编辑:将nullSafeStringComparator提升为静态。


2
关于嵌套的 "if" ... 我发现对于这种情况,嵌套的 if 不太易读,所以我避免使用它。是的,有时会因此进行不必要的比较。参数的最终声明并非必需,但是这是个好主意。 - Eddie
9
@phihag - 我知道已经超过3年了,但是... final关键字并不是真正必要的(Java代码已经足够冗长了)。然而,它可以防止将参数重用为局部变量(这是一种糟糕的编程习惯)。随着我们对软件的集体理解逐渐提高,我们知道事物应该默认为final/const/inmutable。因此,我更喜欢在参数声明中使用final来得到“准默认情况下不可变性”,即使函数可能非常简单,也要多写一些冗长的代码。从整体上看,其可读性和可维护性开销微不足道。 - luis.espinal
26
我不同意 @James McMahon 的观点。Xor (^) 可以简单地用不等于 (!=) 代替,甚至编译出的字节码都一样。在使用 != 和 ^ 中做选择只是个人口味和可读性的问题。所以,从你惊讶的反应来看,我认为它并不适用于这里。当你尝试计算校验和时,请使用 xor。在大多数其他情况下(如本例),让我们坚持使用 !=。 - bvdb
2
@bvdb:如果首先进行 one==null && two==null 的测试,那么其他情况可以使用 one==null || two==null 使代码更易读。为此,我建议:if (one==null || two==null) { if (one==two) return 0; return lhs==null ? -1 : 1; } - Harvey
5
可以通过将String替换为T,同时声明T为<T extends Comparable<T>>来轻松扩展这个答案... 然后我们就可以安全地比较任何可空的Comparable对象。 - Thierry
显示剩余5条评论

23
请参见本答案底部,使用Guava更新(2013年)的解决方案。

这就是我最终采用的方案。结果我们已经有了一个安全处理null的字符串比较的实用方法,所以最简单的解决方案就是利用它。(这是一个庞大的代码库;很容易错过这种东西 :)

public int compareTo(Metadata other) {
    int result = StringUtils.compare(this.getName(), other.getName(), true);
    if (result != 0) {
        return result;
    }
    return StringUtils.compare(this.getValue(), other.getValue(), true);
}

这是助手的定义方式(它是重载的,所以您也可以定义 nulls 是首先还是最后,如果您需要的话):
public static int compare(String s1, String s2, boolean ignoreCase) { ... }

因此,这本质上与Eddie的答案(尽管我不会称静态帮助程序方法为比较器)和uzhin的答案相同。

无论如何,总的来说,我会强烈支持Patrick的解决方案,因为我认为在可能的情况下使用已建立的库是一个好习惯。(正如Josh Bloch所说的那样,了解并使用库。)但在这种情况下,这不会产生最清晰、最简单的代码。

编辑(2009年):Apache Commons Collections版本

实际上,这里有一种使基于Apache Commons NullComparator的解决方案更简单的方法。将其与String类中提供的不区分大小写的Comparator结合使用:

public static final Comparator<String> NULL_SAFE_COMPARATOR 
    = new NullComparator(String.CASE_INSENSITIVE_ORDER);

@Override
public int compareTo(Metadata other) {
    int result = NULL_SAFE_COMPARATOR.compare(this.name, other.name);
    if (result != 0) {
        return result;
    }
    return NULL_SAFE_COMPARATOR.compare(this.value, other.value);
}

现在这个很优雅,我认为。 (只剩下一个小问题:Commons NullComparator不支持泛型,因此存在未检查的分配。)

更新(2013年):Guava版本

将近5年后,这是我如何解决我的原始问题。 如果使用Java编码,我会(当然)使用Guava。(而且几乎肯定不会使用Apache Commons。)

将此常量放在某个地方,例如在“StringUtils”类中:

public static final Ordering<String> CASE_INSENSITIVE_NULL_SAFE_ORDER =
    Ordering.from(String.CASE_INSENSITIVE_ORDER).nullsLast(); // or nullsFirst()

然后,在public class Metadata implements Comparable<Metadata>中:

@Override
public int compareTo(Metadata other) {
    int result = CASE_INSENSITIVE_NULL_SAFE_ORDER.compare(this.name, other.name);
    if (result != 0) {
        return result;
    }
    return CASE_INSENSITIVE_NULL_SAFE_ORDER.compare(this.value, other.value);
}    

当然,这与Apache Commons版本几乎相同(两者都使用JDK的CASE_INSENSITIVE_ORDER),只有nullsLast()的使用是Guava特有的。之所以更喜欢这个版本,仅仅是因为作为依赖项,Guava比Commons Collections更好用。(正如所有人都同意的那样。)
如果你想了解Ordering,请注意它实现了Comparator。它非常方便,特别是对于更复杂的排序需求,例如允许您使用compound()链接多个Orderings。阅读Ordering Explained获取更多信息!

2
String.CASE_INSENSITIVE_ORDER确实能让解决方案更加简洁。更新得很好。 - Patrick
2
如果您已经使用Apache Commons,那么有一个ComparatorChain,因此您不需要自己编写compareTo方法。 - amoebe

14

我建议您使用Apache Commons,因为它通常比您自己编写的代码更好。此外,您可以做“真正”的工作,而不是重新发明轮子。

您感兴趣的类是Null Comparator。它允许您将null设置为高值或低值。当两个值不为null时,您还可以提供自己的比较器。

在您的情况下,您可以有一个静态成员变量来执行比较,然后您的compareTo方法只需引用该变量。

类似这样的东西

class Metadata implements Comparable<Metadata> {
private String name;
private String value;

static NullComparator nullAndCaseInsensitveComparator = new NullComparator(
        new Comparator<String>() {

            @Override
            public int compare(String o1, String o2) {
                // inputs can't be null
                return o1.compareToIgnoreCase(o2);
            }

        });

@Override
public int compareTo(Metadata other) {
    if (other == null) {
        return 1;
    }
    int res = nullAndCaseInsensitveComparator.compare(name, other.name);
    if (res != 0)
        return res;

    return nullAndCaseInsensitveComparator.compare(value, other.value);
}

}

即使你决定自己编写,也要记住这个类,因为它在对包含空元素的列表进行排序时非常有用。

谢谢,我有点希望Commons里会有这样的东西!然而,在这种情况下,我最终没有使用它:https://dev59.com/VXRB5IYBdhLWcg3w4bAo#500643 - Jonik
刚刚意识到你的方法可以通过使用String.CASE_INSENSITIVE_ORDER来简化;请查看我编辑后的跟进答案。 - Jonik
这很好,但是“if (other == null) {”检查不应该在那里。Comparable的Javadoc说,如果other为null,则compareTo应该抛出NullPointerException。 - Daniel Alexiuc

7

我知道这可能并不直接回答你的问题,因为你说需要支持null值。

但是我想指出,在compareTo中支持null值与官方Comparable文档中描述的compareTo协定不一致:

请注意,null不是任何类的实例,e.compareTo(null)应该抛出NullPointerException,即使e.equals(null)返回false。

所以,我会明确地抛出NullPointerException,或者只是在第一次取消引用null参数时抛出异常。


6
您可以使用提取方法:
public int cmp(String txt, String otherTxt)
{
    if ( txt == null )
        return otherTxt == null ? 0 : 1;
     
    if ( otherTxt == null )
          return 1;

    return txt.compareToIgnoreCase(otherTxt);
}

public int compareTo(Metadata other) {
   int result = cmp( name, other.name); 
   if ( result != 0 )  return result;
   return cmp( value, other.value); 

}


3
“0 : 1” 不应该改为 “0 : -1” 吗? - Rolf Kristensen

4
你可以设计你的类为不可变类(参见Effective Java 2nd Ed.的第15项:最小化可变性),并确保在构建时不会出现null(如果需要,可以使用空对象模式)。然后,您可以跳过所有这些检查,并安全地假定值不为null。

是的,那通常是一个不错的解决方案,并且简化了很多事情 - 但在这里,我更感兴趣的是允许空值的情况,出于某种原因,必须考虑到它们 :) - Jonik

4
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Comparator;

public class TestClass {

    public static void main(String[] args) {

        Student s1 = new Student("1","Nikhil");
        Student s2 = new Student("1","*");
        Student s3 = new Student("1",null);
        Student s11 = new Student("2","Nikhil");
        Student s12 = new Student("2","*");
        Student s13 = new Student("2",null);
        List<Student> list = new ArrayList<Student>();
        list.add(s1);
        list.add(s2);
        list.add(s3);
        list.add(s11);
        list.add(s12);
        list.add(s13);

        list.sort(Comparator.comparing(Student::getName,Comparator.nullsLast(Comparator.naturalOrder())));

        for (Iterator iterator = list.iterator(); iterator.hasNext();) {
            Student student = (Student) iterator.next();
            System.out.println(student);
        }


    }

}

输出结果为:

Student [name=*, id=1]
Student [name=*, id=2]
Student [name=Nikhil, id=1]
Student [name=Nikhil, id=2]
Student [name=null, id=1]
Student [name=null, id=2]

2
我们可以使用Java 8来进行对象之间的空值友好比较。 假设我有一个Boy类,有两个字段:String name和Integer age,我想首先比较名字,然后再比较年龄(如果名字相等)。
static void test2() {
    List<Boy> list = new ArrayList<>();
    list.add(new Boy("Peter", null));
    list.add(new Boy("Tom", 24));
    list.add(new Boy("Peter", 20));
    list.add(new Boy("Peter", 23));
    list.add(new Boy("Peter", 18));
    list.add(new Boy(null, 19));
    list.add(new Boy(null, 12));
    list.add(new Boy(null, 24));
    list.add(new Boy("Peter", null));
    list.add(new Boy(null, 21));
    list.add(new Boy("John", 30));

    List<Boy> list2 = list.stream()
            .sorted(comparing(Boy::getName, 
                        nullsLast(naturalOrder()))
                   .thenComparing(Boy::getAge, 
                        nullsLast(naturalOrder())))
            .collect(toList());
    list2.stream().forEach(System.out::println);

}

private static class Boy {
    private String name;
    private Integer age;
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public Integer getAge() {
        return age;
    }
    public void setAge(Integer age) {
        this.age = age;
    }
    public Boy(String name, Integer age) {
        this.name = name;
        this.age = age;
    }

    public String toString() {
        return "name: " + name + " age: " + age;
    }
}

结果如下:

    name: John age: 30
    name: Peter age: 18
    name: Peter age: 20
    name: Peter age: 23
    name: Peter age: null
    name: Peter age: null
    name: Tom age: 24
    name: null age: 12
    name: null age: 19
    name: null age: 21
    name: null age: 24

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接