stream中去除相同的元素

给一个集合中的元素去重算是一个比较常见的任务需求了。这里记录一下通过stream来对元素去重操作的两种方法。

distinct方法

stream本身提供了distinct()这样的方法来对集合中的元素进行去重操作。不过默认情况下对于集合中的自定义类是无能为力的。
看了下关于distinct()中的注释,他是利用equals()方法来判断集合中的对象是否完全相等来去重的。
因此如果是自定义类,可以通过重写equals()方法来达到目的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Student {
private String name;
private int age;

public Student(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public int getAge() {
return age;
}

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

@Override
public String toString() {
return "{ name: \"" + name + "\", age: " + age + " }";
}

@Override
public boolean equals(Object obj) {
if (this == obj) { return true; }
if (obj == null || getClass() != obj.getClass()) { return false; }
Student s = (Student)obj;
if (this.name == null) {
if (s.name != null) { return false; }
} else if (!this.name.equals(s.name)) { return false; }
if (this.age != s.age) { return false; }
return true;
}

@Override
public int hashCode() {
return Objects.hash(name, age);
}
}

这里定义了一个Student类,然后通过判断name与age属性来判断对象是否相等。
需要注意的是,点开equals()方法会看到以下注释:

请注意,通常每当重写此方法时,都必须重写{@code hashCode}方法,以便维护{@code hashCode}方法的常规协定,该协定规定相等的对象必须具有相等的哈希码。

因此也不要忘记了重写hashCode()方法。接下来测试一下。

1
2
3
4
5
List<Student> students = new Random().ints(10, 10, 16).boxed()
.map(i -> new Student(String.valueOf((char)(55+i)), i)).collect(Collectors.toList());
students.forEach(System.out::println);
System.out.println("-----------------------------------");
students.stream().distinct().forEach(System.out::println);

输出的结果为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{ name: "C", age: 12"}
{ name: "E", age: 14"}
{ name: "A", age: 10"}
{ name: "A", age: 10"}
{ name: "C", age: 12"}
{ name: "A", age: 10"}
{ name: "C", age: 12"}
{ name: "A", age: 10"}
{ name: "F", age: 15"}
{ name: "B", age: 11"}
-----------------------------------
{ name: "C", age: 12"}
{ name: "E", age: 14"}
{ name: "A", age: 10"}
{ name: "F", age: 15"}
{ name: "B", age: 11"}

可以看到distinct()方法已经生效了。

filter方法

如果不想修改自定义类的equals()hashCode()方法,就可以利用filter方法来过滤掉重复的类。
filter方法接收的是一个Predicate,他可以筛选出在stream中与该Predicate匹配的元素,
因此可以写个去重的Predicate来达到目的:

1
2
3
4
5
6
7
public static <T> Predicate<T> distinctByKeys(Function<? super T, ?>... keyExtractors) {
Map<Object, Boolean> map = new ConcurrentHashMap<>();
return t -> {
List<?> keys = Arrays.stream(keyExtractors).map(k -> k.apply(t)).collect(Collectors.toList());
return map.putIfAbsent(keys, Boolean.TRUE) == null;
};
}

Function有点类似匿名函数,他通过传入一个参数,然后可以返回一个结果。
这里通过将这些Function的执行结果组成一个列表当作Map的key存入到指定的Map中,
putIfAbset方法与put方法不同点在于它并不会覆盖掉Map中已存在的键值对,并且还会返回该键所对应的值。
所以如果Map中没有该键值对时,必然返回的值为null
因此第一次放入Map中的元素都能成功放入,返回的都是null,如果Map本身已包含了该元素,则不会返回null,说明该元素已经重复了。
这里主要是通过键来过滤元素,对值的要求不高,使用Boolean.True对象是因为它占用的字节数较少。
因为stream有可能通过parallel()方法来变成一个parallelStream,因此需要使用ConcurrentHashMap来确保多线程下的访问。
最终配合filter()方法可以过滤掉重复的元素。

1
students.stream().filter(distinctByKeys(Student::getName, Student::getAge)).forEach(System.out::println);

最终输出结果与上边过滤结果一致。

如果只是想要利用类的单个属性来过滤,则直接将Function的执行结果作为key即可:

1
2
3
4
5
6
public static <T> Predicate<T> distinctByKey(Function<? super T, Object> keyExtractor) {
Map<Object, Boolean> map = new ConcurrentHashMap<>();
return t -> map.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE) == null;
}

students.stream().filter(distinctByKey(Student::getName)).forEach(System.out::println);