如何根据来自不同Java类的字段比较两个集合的“等效性”?

给定任意两个类,例如 及以下:ClassAClassB

class ClassA {
    private int intA;
    private String strA;
    private boolean boolA;
    // Constructor
    public ClassA (int intA, String strA, boolean boolA) {
        this.intA = intA; this.strA = strA; this.boolA = boolA;
    } // Getters and setters etc. below...
}

class ClassB {
    private int intB;
    private String strB;
    private boolean boolB;
    // Constructor
    public ClassB (int intB, String strB, boolean boolB) {
        this.intB = intB; this.strB = strB; this.boolB = boolB;
    } // Getters and setters etc. below...
}

以及任何两种不同的类型,一种带有元素,另一种具有元素,例如:CollectionClassAClassB

List<Object> myList = Arrays.asList(new ClassA(1, "A", true),
                                    new ClassA(2, "B", true));
Set<Object> mySet = new HashSet<Object>(
                      Arrays.asList(new ClassB(1, "A", false),
                                    new ClassB(2, "B", false)));

判断两个 s 在指定的字段子集方面是否“等价”(*) 的最简单方法是什么?Collection

(*)使用“等同”一词而不是“平等”一词,因为这是上下文的,即这种“等同”在另一种情况下可能有不同的定义。

上面的工作示例:假设我们指定 and 应分别与 和 匹配(但 / 值可以忽略)。这将使上面定义的两个集合对象被视为等效的 - 但如果在其中一个集合中添加或删除元素,则它们将不再是等效的。intAstrAintBstrBboolAboolB

首选解决方案:对于任何类型,使用的方法都应该是泛型的。理想情况下,Java 7仅限于使用它(但Java 8可能会引起其他人的额外兴趣)。乐于使用Guava或Apache Commons,但不希望使用更晦涩的外部库。Collection


答案 1

这是一个使用lambdas和高阶函数的Java 8版本。使用匿名内部类而不是lambdas将其转换为Java 7可能是可能的。(我相信大多数IDE都有一个重构操作来做到这一点。我将把它作为有兴趣的读者的练习。

这里实际上有两个不同的问题:

  1. 给定两个不同类型的对象,通过检查每个对象的相应字段来评估它们。这与 JDK 库 API 已经定义的“equals”和“compare”操作不同,因此我将使用术语“等效”来代替。

  2. 给定两个包含这些类型元素的集合,确定它们对于该术语的某些定义是否“相等”。这实际上是相当微妙的;请参阅下面的讨论。

1. 等效性

给定两个类型的对象,我们想要确定它们是否等效。结果是一个布尔值。这可以用 类型的函数表示。但是我们不一定能直接检查对象;相反,我们需要从每个对象中提取各自的字段,并相互评估提取结果。如果从中提取的字段是类型,而从中提取的字段是类型,则提取器由函数类型表示TUBiPredicate<T,U>TTRUUR

Function<T, TR>
Function<U, UR>

现在我们已经提取了类型和 的结果。我们可以只调用它们,但这是不必要的限制。相反,我们可以提供另一个等价函数,该函数将被调用以相互评估这两个结果。那是一个.TRURequals()BiPredicate<TR,UR>

鉴于所有这些,我们可以编写一个高阶函数,该函数接受所有这些函数并为我们生成和等价函数(为了完整性而包含通配符):

static <T,U,TR,UR> BiPredicate<T,U> equiv(Function<? super T, TR> tf,
                                          Function<? super U, UR> uf,
                                          BiPredicate<? super TR, ? super UR> pred) {
    return (t, u) -> pred.test(tf.apply(t), uf.apply(u));
}

使用 来评估字段提取结果可能是一种常见情况,因此我们可以为此提供重载:equals()

static <T,U> BiPredicate<T,U> equiv(Function<? super T, ?> tf,
                                    Function<? super U, ?> uf) {
    return (t, u) -> equiv(tf, uf, Object::equals).test(t, u);
}

我本可以提供另一个类型变量作为两个函数的结果类型,以确保它们是相同的类型,但事实证明这不是必需的。由于 定义了 on 并且它需要一个参数,我们实际上并不关心函数返回类型是什么,因此通配符。Requals()ObjectObject

下面介绍如何使用它来仅使用字符串字段来评估 OP 的示例类:

ClassA a = ... ;
ClassB b = ... ;
if (equiv(ClassA::getStrA, ClassB::getStrB).test(a, b)) {
    // they're equivalent
}

作为变体,我们可能还需要一个原始的特化,以避免不必要的装箱:

static <T,U> BiPredicate<T,U> equivInt(ToIntFunction<? super T> tf,
                                       ToIntFunction<? super U> uf) {
    return (t, u) -> tf.applyAsInt(t) == uf.applyAsInt(u);
}

这使我们能够基于单个字段构造等价函数。如果我们想基于多个字段评估等效性,该怎么办?我们可以通过链接该方法来组合任意数量的 BiPredicates。下面介绍如何创建一个函数,该函数使用 OP 示例中类的 和 字段来评估等价性。为此,最好将函数与使用它分开存储在变量中,尽管这可能都是内联的(我认为这会使它不可读):and()intString

BiPredicate<ClassA, ClassB> abEquiv =
    equivInt(ClassA::getIntA, ClassB::getIntB)
        .and(equiv(ClassA::getStrA, ClassB::getStrB));

if (abEquiv.test(a, b)) {
    // they're equivalent
}

作为最后一个示例,在为两个类创建等价函数时,能够为字段提取结果提供等价函数是非常强大的。例如,假设我们要提取两个 String 字段,如果提取的字符串相等,则认为它们是等效的,忽略大小写。下面的代码导致:true

equiv(ClassA::getStrA, ClassB::getStrB, String::equalsIgnoreCase)
    .test(new ClassA(2, "foo", true),
          new ClassB(3, "FOO", false))

2. 收集“平等”

第二部分是评估两个集合在某种意义上是否“相等”。问题在于,在集合框架中,相等的概念被定义为一个列表只能等于另一个列表,而一个集合只能等于另一个集合。因此,某些其他类型的集合永远不能等于 List 或 Set。有关这一点的一些讨论,请参阅 Collection.equals() 的规范。

这显然与OP想要的不一致。正如OP所建议的那样,我们并不真正想要“平等”,但我们想要一些其他属性,我们需要为其提供定义。根据OP的例子,以及Przemek Gumulajanos在其他答案中的一些建议,似乎我们希望两个集合中的元素以某种方式一对一对应。我称之为双射,在数学上可能并不精确,但它似乎足够接近。此外,每对元素之间的对应关系应为上文定义的等效性

计算这有点微妙,因为我们有自己的等价关系。我们不能使用许多内置的收集操作,因为它们都使用 .我的第一次尝试是这样的:equals()

// INCORRECT
static <T,U> boolean isBijection(Collection<T> c1,
                                 Collection<U> c2,
                                 BiPredicate<? super T, ? super U> pred) {
    return c1.size() == c2.size() &&
           c1.stream().allMatch(t -> c2.stream()
                                       .anyMatch(u -> pred.test(t, u)));
}

(这与Przemek Gumula给出的基本上相同。这存在问题,归结为一个集合中有多个元素与另一个集合中的单个元素相对应的可能性,从而使元素不匹配。如果给定两个多集,使用相等性作为等价函数,这将给出奇怪的结果:

{a x 2, b}    // essentially {a, a, b}
{a, b x 2}    // essentially {a, b, b}

此函数将这两个多集视为双射,但事实显然并非如此。如果等价函数允许多对一匹配,则会出现另一个问题:

Set<String> set1 = new HashSet<>(Arrays.asList("foo", "FOO", "bar"));
Set<String> set2 = new HashSet<>(Arrays.asList("fOo", "bar", "quux"));

isBijection(set1, set2, equiv(s -> s, s -> s, String::equalsIgnoreCase))

结果是 ,但如果集合的顺序相反,则结果为 。这显然是错误的。truefalse

另一种算法是创建一个临时结构,并在元素匹配时将其删除。结构必须考虑重复项,因此我们需要减少计数,并且仅在计数达到零时才删除元素。幸运的是,各种Java 8功能使这变得非常简单。这与janos的答案中使用的算法非常相似,尽管我已经将等价函数提取到方法参数中。唉,因为我的等价函数可以有嵌套的等价函数,这意味着我无法探测映射(由相等性定义)。相反,我必须搜索地图的键,这意味着算法是O(N^2)。哦,好吧。

但是,代码非常简单。首先,使用 从第二个集合生成频率图。然后,迭代第一个集合的元素,并搜索频率图的键以查找等效项。如果找到一个,则其发生次数将递减。请注意传递给 Map.compute() 的重新映射函数的返回值。这会产生删除条目的副作用,而不是将映射设置为 。这有点像API黑客,但它非常有效。groupingBynullnull

对于第一个集合中的每个元素,必须在第二个集合中找到一个等效元素,否则它将被救助。在处理完第一个集合的所有元素后,频率图中的所有元素也应已处理完毕,因此只需对其进行空测试即可。

代码如下:

static <T,U> boolean isBijection(Collection<T> c1,
                                 Collection<U> c2,
                                 BiPredicate<? super T, ? super U> pred) {
    Map<U, Long> freq = c2.stream()
                          .collect(Collectors.groupingBy(u -> u, Collectors.counting()));
    for (T t : c1) {
        Optional<U> ou = freq.keySet()
                             .stream()
                             .filter(u -> pred.test(t, u))
                             .findAny();
        if (ou.isPresent()) {
            freq.compute(ou.get(), (u, c) -> c == 1L ? null : c - 1L);
        } else {
            return false;
        }
    }

    return freq.isEmpty();
}

目前还不完全清楚这个定义是否正确。但从直觉上讲,这似乎是人们想要的。不过,它很脆弱。如果等价函数不对称,将失败。还有一些自由度没有被考虑在内。例如,假设集合是isBijection

{a, b}
{x, y}

并且等价于 和 ,但只等价于 。如果 与 匹配,则的结果为 。但是如果与 匹配,结果将是 。axybxaxisBijectionfalseaytrue

把它放在一起

下面是 OP 的示例,使用 、 和 函数进行编码:equiv()equivInt()isBijection

List<ClassA> myList = Arrays.asList(new ClassA(1, "A", true),
                                    new ClassA(2, "B", true));

Set<ClassB> mySet = new HashSet<>(Arrays.asList(new ClassB(1, "A", false),
                                                new ClassB(2, "B", false)));

BiPredicate<ClassA, ClassB> abEquiv =
    equivInt(ClassA::getIntA, ClassB::getIntB)
        .and(equiv(ClassA::getStrA, ClassB::getStrB));

isBijection(myList, mySet, abEquiv)

这样做的结果是 。true


答案 2

另一种可能的解决方案是编写一个简单的比较方法和谓词(因此您可以显式指定两个类的条件,使其在您的术语上相似)。我在Java 8中创建了这个:

<T, U> boolean compareCollections(Collection<T> coll1, Collection<U> coll2, BiPredicate<T, U> predicate) {

    return coll1.size() == coll2.size()
            && coll1.stream().allMatch(
            coll1Item -> coll2.stream().anyMatch(col2Item -> predicate.test(coll1Item, col2Item))
    );
}

如您所见,它会比较大小,然后检查集合中的每个元素在第二个集合中是否有对应元素(尽管它不是比较顺序)。它在Java 8中,但你可以通过实现一个简单的BiPredicate代码allMatch和anyMatch将其移植到Java 7(每个代码的一个for循环应该足够了)

编辑:Java 7代码:

<T, U> boolean compareCollections(Collection<T> coll1, Collection<U> coll2, BiPredicate<T, U> predicate) {

        if (coll1.size() != coll2.size()) {
            return false;
        }

        for (T item1 : coll1) {
            boolean matched = false;
            for (U item2 : coll2) {
                if (predicate.test(item1, item2)) {
                    matched = true;
                }
            }

            if (!matched) {
                return false;
            }
        }
        return true;

    }}
interface BiPredicate <T, U> {
    boolean test(T t, U u);
}

下面是一个用法示例

单元测试:

class UnitTestAppleClass {
    private int appleKey;

    private String appleName;

    public UnitTestAppleClass(int appleKey, String appleName) {
        this.appleKey = appleKey;
        this.appleName = appleName;
    }

    public int getAppleKey() {
        return appleKey;
    }

    public String getAppleName() {
        return appleName;
    }

    public void setAppleName(String appleName) {
        this.appleName = appleName;
    }
}

class UnitTestOrangeClass {
    private int orangeKey;

    private String orangeName;

    public UnitTestOrangeClass(int orangeKey, String orangeName) {
        this.orangeKey = orangeKey;
        this.orangeName = orangeName;
    }

    public int getOrangeKey() {
        return orangeKey;
    }

    public String getOrangeName() {
        return orangeName;
    }
}

@Test
public void compareNotSameTypeCollectionsOkTest() {

    BiPredicate<UnitTestAppleClass, UnitTestOrangeClass> myApplesToOrangesCompareBiPred = (app, org) -> {
    /* you CAN compare apples and oranges */
        boolean returnValue =
                app.getAppleKey() == org.getOrangeKey() && app.getAppleName().equals(org.getOrangeName());
        return returnValue;
    };

    UnitTestAppleClass apple1 = new UnitTestAppleClass(1111, "Fruit1111");
    UnitTestAppleClass apple2 = new UnitTestAppleClass(1112, "Fruit1112");
    UnitTestAppleClass apple3 = new UnitTestAppleClass(1113, "Fruit1113");
    UnitTestAppleClass apple4 = new UnitTestAppleClass(1114, "Fruit1114");

    Collection<UnitTestAppleClass> apples = asList(apple1, apple2, apple3, apple4);

    /* same "key" VALUES, and "name" VALUES, but different property names */
    UnitTestOrangeClass orange1 = new UnitTestOrangeClass(1111, "Fruit1111");
    UnitTestOrangeClass orange2 = new UnitTestOrangeClass(1112, "Fruit1112");
    UnitTestOrangeClass orange3 = new UnitTestOrangeClass(1113, "Fruit1113");
    UnitTestOrangeClass orange4 = new UnitTestOrangeClass(1114, "Fruit1114");

    Collection<UnitTestOrangeClass> oranges = asList(orange1, orange2, orange3, orange4);

    boolean applesAndOrangeCheck = EqualsHelper.<UnitTestAppleClass, UnitTestOrangeClass> compareCollections(apples, oranges, myApplesToOrangesCompareBiPred);
    assertTrue(applesAndOrangeCheck);

    /* alter one of the apples */
    if( apples.stream().findFirst().isPresent())
    {
        apples.stream().findFirst().get().setAppleName("AppleChangedNameOne");

        boolean alteredAppleAndOrangeCheck = EqualsHelper.<UnitTestAppleClass, UnitTestOrangeClass> compareCollections(apples, oranges, myApplesToOrangesCompareBiPred);
        assertFalse(alteredAppleAndOrangeCheck);
    }

    Collection<UnitTestAppleClass> reducedApples = asList(apple1, apple2, apple3);

    boolean reducedApplesAndOrangeCheck = EqualsHelper.<UnitTestAppleClass, UnitTestOrangeClass> compareCollections(reducedApples, oranges, myApplesToOrangesCompareBiPred);
    assertFalse(reducedApplesAndOrangeCheck);
}