集合類再探:不可變類的好處,Collector介面詳解,使用內部迭代

大華dijkstra發表於2023-02-23

集合類再探

注:本文使用的pom依賴見文末。

java語言層面支援對實現了Iterable介面的物件使用for-each語句。Iterator可以實現有限流和無限流。

Collection類定義了基本的增刪改查操作,轉向基本陣列型別(toArray),1.8引入了stream操作。

可變與不可變

不可變集合看似是限制,但是其會極大簡化了程式設計的心理負擔。

心理負擔舉例:

我們使用一個List物件,對其修改的操作必須小心翼翼,因為寬介面的問題,add之類的操作很可能不支援。

stream 操作在其他類庫上不一定有效,因為default方法不一定適用於所有子類。

一個集合物件作為方法的入參,有可能被方法修改,而這種修改我們很難輕易地理解,需要閱讀程式碼或者註釋。一個方法不能複用常常是因為新增了過多的副作用,而這種副作用暗含其中,為我們的專案新增了一顆顆隱形炸彈。註釋的產生只能說明程式碼設計存在一定的缺陷,優秀的程式碼應該減少不必要的註釋,顯然對於副作用,我們必須要顯著說明,比如可能丟擲的異常。

ImmutableList<String> list = ...
foo(list)
boo(list)
zoo(list)
doSomethingWith(list)
// 如上的幾個方法互不影響,可以繼續放心地使用 list
// 如果list的型別是List,這幾個方法的入參很可能都不一樣

guava 和很多其他工具類都是按照這種思想設計的:

// Guava
// builder 模式
ImmutableList<Integer> list = ImmutableList.<Integer>builder()
                .add(1)
                .add(2)
                .addAll(otherList)
                .build();
// 靜態工廠
ImmutableList<Integer> list = ImmutableList.of(1, 2, 3);
// shallow copy
ImmutableList<Integer> list = ImmutableList.copyOf(new Integer[]{1, 2, 3});

協變的意思是物件的繼承會在集合的維度上傳遞,不可變型別由於不支援修改,對於協變的支援理所當然。

Java不支援類定義時定義協變,只支援使用集合物件時使用萬用字元,所以我們能在許多方法上看到泛型萬用字元。

/ # Guava.ImmutableList
public static <E> ImmutableList<E> copyOf(Collection<? extends E> elements) {
    if (elements instanceof ImmutableCollection) {
      @SuppressWarnings("unchecked") // all supported methods are covariant
      ImmutableList<E> list = ((ImmutableCollection<E>) elements).asList();
      return list.isPartialView() ? ImmutableList.<E>asImmutableList(list.toArray()) : list;
    }
    return construct(elements.toArray());
  }
// elements 入參後,如果不進行修改,可以@SuppressWarnings("unchecked"),直接轉換型別為不變,方便後續使用。
// code1
// 請思考這段程式碼的執行結果
Random random = new Random();
List<Integer> list = random.ints(6L).boxed().collect(Collectors.toList());
System.out.println("list = " + list);
List<Integer> subList = list.subList(0, 3);
System.out.println("subList = " + subList);

Collections.sort(list);

System.out.println("list = " + list);
System.out.println("subList = " + subList);

// 以上程式碼的執行結果
/**
list = [40, 60, 28, 4, 83, 90]
subList = [40, 60, 28]
list = [4, 28, 40, 60, 83, 90]
Exception in thread "main" java.util.ConcurrentModificationException
**/
// 我們發現:subList這個變數在sort操作之後,不能使用了

// code2
Random random = new Random();
List<Integer> _list = random.ints(6L, 0, 100).boxed().collect(Collectors.toList());
ImmutableList<Integer> list = ImmutableList.copyOf(_list);
System.out.println("list = " + list);
List<Integer> subList = list.subList(0, 3);
System.out.println("subList = " + subList);

Collections.sort(list);

System.out.println("list = " + list);
System.out.println("subList = " + subList);

// 以上程式碼的執行結果
/**
list = [22, 34, 50, 49, 93, 49]
subList = [22, 34, 50]
Exception in thread "main" java.lang.UnsupportedOperationException
	at com.google.common.collect.ImmutableList.sort(ImmutableList.java:581)
	at java.util.Collections.sort(Collections.java:141)
**/
// 雖然編譯透過了,但是 list 禁止了修改,同時由於沒有直接呼叫list.sort()方法,在執行前我們無法獲取編譯的提示。
// 使用 list.~~sort~~(null); 會得到 IDEA inspection 提示,因為Immutable類的sort標註為了@Deprecate

// code3
Random random = new Random();
List<Integer> _list = random.ints(6L, 0, 100).boxed().collect(Collectors.toList());
ImmutableList<Integer> list = ImmutableList.copyOf(_list);
System.out.println("list = " + list);
List<Integer> subList = list.subList(0, 3);
System.out.println("subList = " + subList);

ImmutableList<Integer> sortedList = list.stream().sorted().collect(ImmutableList.toImmutableList());

System.out.println("list = " + list);
System.out.println("subList = " + subList);
System.out.println("sortedList = " + sortedList);
ImmutableList<Integer> sortedSubList = sortedList.subList(0, 3);
System.out.println("sortedSubList = " + sortedSubList);

// 以上程式碼的執行結果
/**
list = [53, 7, 69, 5, 23, 7]
subList = [53, 7, 69]
list = [53, 7, 69, 5, 23, 7]
sortedList = [5, 7, 7, 23, 53, 69]
subList = [53, 7, 69]
sortedSubList = [5, 7, 7]
**/
// 可以看出一旦確定list, subList,不管後續進行如何複雜的操作,其值都不變。
// 使用安全的方法,stream(), sorted(), collect()等,可以保證方法無副作用。
// Collections.sort(List<T> list) 方法有副作用

其實 IDEA 已經為我們提供了相關的提示:

image

我們可以在@Contract註解中看到,入參list被修改了。同時註釋裡表明了入參、出參、以及可能的異常。Implementation Note 給出了提示。

Collector 介面

// A: 容器, T: 源型別, R: 最終型別(一般為T)
public interface Collector<T, A, R> {
    Supplier<A> supplier();

    BiConsumer<A, T> accumulator();

    BinaryOperator<A> combiner();

    Function<A, R> finisher();

    Set<Characteristics> characteristics();
}

簡單來說,Collectors 對集合型別進行了reduce運算,supplier提供容器,accmulater 新增元素到容器,combiner 聯結多個容器,也就是說,reduce
可以分組進行運算,每個組為一個容器,然後合併各個容器,·finisher進行最終運算,一般為不可變型別的再封裝,比如將 List 封裝為 ImmutableList。characteristics 指定了Collector的特性,包括

CONCURRENT, UNORDERED, IDENTITY_FINISH,我們忽略CONCURRENT,因為

  1. 多線性程式設計的複雜性,不推薦使用 Stream 做多執行緒處理。Stream流處理進行多執行緒需要調優,預設使用的commonPool,不好控制,commonPool適用於計算密集型任務。
  2. Stream 不適合做精細控制,不好除錯。
  3. 不要過早調優。絕大部分情況下不要使用多執行緒。
  4. 就算需要使用多執行緒,還不如直接使用執行緒安全類,對集合進行迭代處理。
  5. 加個parallel 不一定增加效能,最好會編寫 Spliterator

Collectors 工具類提供了collector, 常用的有以下一些:

  • toMap 轉換為map
  • groupingBy 分組,返回結果為Map<K, Collection>
  • partition 分成兩組,返回結果為Map<Boolean, Collection>
  • toCollection
  • toList
  • toSet PS: 如果一個容器可以是集合,那麼就應該使用 Set,而不是所有的集合類都用 List 表示。
  • joining 字串拼接

一些常用的工具方法如下,通常用來當做中間步驟:

  • collectingAndThen 新增 finisher,常用來建立ImmutableCollection
  • mapping(Function mapper, Collector downstream) 實現多層收集,如註解中的示例:
public class MultiLayerStreamDemo {
    public static void main(String[] args) {
        Map<City, Set<String>> lastNamesByCity
                = people.stream().collect(groupingBy(Person::getCity,
                mapping(Person::getLastName, toSet())));
    }
}

其他的方法幾乎不用,甚至可以用其他的方法代替:

  • summarizingInt/Long/Double() 返回統計資料,包括sum,average, max, min;很多工具類可以直接計算,比如Ints
  • averagingInt 返回平均值,很多工具類就可以完成
  • maxBy 返回最大值,Stream自己就帶有max,min方法
  • counting 計數,因為 Stream 流只能用一次,所以不常用;不如直接轉換為集合再呼叫size方法。
  • reduce Stream自己就帶有reduce 方法

BUT,標準庫的缺陷

雖然我們可以建立List, Map<K, Collection<V>>, Optional<T>(reduce建立)等容器類,但是標準庫提供的能力有限。 對於不可變型別,我們一般建立為 ImmutableCollection
;對於一些容器,我們可以用更精確的容器類來描述; collect 可以作為不同容器的轉換方法:

SpringData

  • Stream => Streamable(支援Iterator介面)

Guava 類庫

  • List => ImmutableList
  • Map<K, Collection> => Multimap<K, V>
  • Map<K, Integer> => Multiset

vavr 類庫

  • List => io.vavr.collection.List(不可變連結串列)
  • Map => io.vavr.collection.Map(不可變Map)
public class CountDemo {
    public static void main(String[] args) {
        String[] words = Stream.generate(new Faker().food()::vegetable)
                .limit(100)
                .toArray(String[]::new);

        String s = "Carrot";
        Map<String, Integer> counts = map1(words);
        System.out.println("counts = " + counts);
        System.out.println("counts.get(s) = " + counts.get(s));

        ImmutableMultiset<String> counts2 = map7(Arrays.asList(words));
        System.out.println("counts2 = " + counts2);
        System.out.println("counts2.count(s) = " + counts2.count(s));
    }

    // 1. 使用map基本方法迭代
    @NotNull
    public static Map<String, Integer> map1(String[] words) {
        Map<String, Integer> counts = new HashMap<>();
        for (String word : words) {
            Integer count = counts.get(word);
            if (count == null) {
                counts.put(word, 1);
            } else {
                counts.put(word, count + 1);
            }
        }
        return counts;
    }

    // 2. 使用 merge 方法
    @NotNull
    public static Map<String, Integer> map2(String[] words) {
        Map<String, Integer> counts = new HashMap<>();
        for (String word : words) {
            counts.merge(word, 1, Integer::sum);
        }
        return counts;
    }

    // 3. forEach 迭代,不推薦
    @NotNull
    public static Map<String, Integer> map3(Iterable<String> words) {
        Map<String, Integer> counts = new HashMap<>();
        words.forEach(word ->
                counts.merge(word, 1, Integer::sum)
        );
        return counts;
    }

    // 4. Stream + Collector
    @NotNull
    public static Map<String, Long> map4(Iterable<String> words) {
        return Streamable.of(words).stream()
                .collect(groupingBy(it -> it, counting()));
    }

    // 5. Stream + 自定義 Collector
    @NotNull
    public static ImmutableMap<String, Integer> map5(Iterable<String> words) {
        return Streamable.of(words).stream()
                .collect(toCountMap());
    }

    @NotNull
    public static <T> Collector<T, ?, ImmutableMap<T, Integer>> toCountMap() {
        Collector<T, ?, Map<T, Integer>> countCollector = groupingBy(it -> it, countInt());
        return collectingAndThen(countCollector, ImmutableMap::copyOf);
    }

    @NotNull
    public static <T> Collector<T, ?, Integer> countInt() {
        return Collectors.reducing(0, e -> 1, Integer::sum);
    }

    // 6. Stream + ImmutableMultiset
    @NotNull
    public static ImmutableMultiset<String> map6(Iterable<String> words) {
        return Streamable.of(words).stream()
                .collect(toImmutableMultiset());
    }

    // 7. ImmutableMultiset 直接建立
    @NotNull
    public static ImmutableMultiset<String> map7(Iterable<String> words) {
        return ImmutableMultiset.copyOf(words);
    }
}

由以上實現可以看出,方法1為一般實現,可能出錯,推薦使用內部迭代(不自己控制迭代過程),如果有工具類或方法,則不建議自己寫(雖然這個例子很簡單)

方法2使用了Map::merge方法,這個方法適用於計數和map合併,ConcurrentMap::merge為原子操作

方法3使用了forEach方法,只在生產者-消費者模型、日誌列印時推薦使用,遍歷Map物件時也可以用

方法4使用標準庫的工具方法,缺點是計數型別為Long,不是我們想要的

方法5為自己編寫的 Collector,基本思路是分組計數,然後用ImmutableMap包裝

7最簡單,若在Stream流中進行filter、map、flatMap等運算,可使用方法6

總之,實際應用時建議使用Immutable型別,對於實際問題,應用對應具體的模型,我們使用counts時,面向的是介面Multiset或抽象類ImmutableMultiset, 封裝了我們需要使用的方法,不易出錯。

以下是一個利用collector機制編寫的排行榜的簡單實現。

public class TopKCollectorDemo {
    public static void main(String[] args) {
        List<Integer> list = new Random().ints(100, 0, 100)
                .boxed().collect(Collectors.toList());
        System.out.println("list = " + list);
        System.out.println("topK(list, 5) = " + topK(list, 5));
    }

    private static class FixSizePQ<E extends Comparable<E>> extends PriorityQueue<E> {
        private final int sz;

        public FixSizePQ(int sz) {
            super(sz);
            assert sz > 0;
            this.sz = sz;
        }

        @Override
        public boolean add(E e) {
            if (size() == sz)
                if (e.compareTo(peek()) > 0) {
                    poll();
                } else {
                    return true;
                }
            return super.add(e);
        }
    }

    @Contract(pure = true)
    public static <T extends Comparable<T>> ImmutableList<T> topK(Iterable<? extends T> iterable, int k) {
        Collector<T, ?, FixSizePQ<T>> tpqCollector = Collector.of(() -> new FixSizePQ<T>(k),
                Collection::add,
                (r1, r2) -> {
                    r1.addAll(r2);
                    return r1;
                },
                Characteristics.UNORDERED);
        return Streams.stream(iterable).collect(
                collectingAndThen(tpqCollector, TopKCollectorDemo::toImmutableList));
    }

    @NotNull
    @Contract(pure = true)
    private static <T extends Comparable<T>> ImmutableList<T> toImmutableList(PriorityQueue<T> pq) {
        List<T> list = new ArrayList<>(pq.size());
        while (!pq.isEmpty()) {
            list.add(pq.poll());
        }
        return ImmutableList.copyOf(list).reverse();
    }
}

外部迭代與內部迭代可以相互轉換

同一個任務可能有多種實現,有時A方法好,有時B方法好,有時兩者有差不多,多種實現之間可以相互轉換。

public class ToMapDemo {

    // 外部迭代
    @NotNull
    public static Map<String, Integer> map2(String[] words) {
        Map<String, Integer> counts = new HashMap<>();
        for (String word : words) {
            counts.merge(word, 1, Integer::sum);
        }
        return counts;
    }

    // IDEA 基於以上方法自動轉換成 Stream 運算
    @NotNull
    public static Map<String, Integer> map2_(String[] words) {
        return Arrays.stream(words).collect(toMap(word -> word, word -> 1, Integer::sum));
    }
}

如上例,對於words的迭代有外部迭代和內部迭代兩種,外部迭代即我們自己控制迭代過程,這裡使用的是 for each 形式,還可以使用 with index 形式; 內部迭代由程式自己實現,其迭代過程不受我們直接控制,優點是不易出錯。
如果你發現一個Stream流過於複雜,不妨利用IDEA 自動轉換為外部迭代方式。

public class ComplicateStreamDemo {
    @Value
    static class User {
        String id;
        String name;
        String mobile;

        public static User generateRandom() {
            Faker faker = new Faker();
            return new User(faker.idNumber().valid(), faker.name().name(), faker.phoneNumber().cellPhone());
        }
    }

    @Value
    static class Pair {
        User a, b;
    }

    public static void main(String[] args) {
        User[] users = Stream.generate(User::generateRandom)
                .limit(5)
                .toArray(User[]::new);
        List<Pair> pairs = f1(users);
        pairs.forEach(System.out::println);
        System.out.println("pairs.size() = " + pairs.size());
    }

    @NotNull
    private static List<Pair> f1(User[] users) {
        return Arrays.stream(users)
                .flatMap(user1 ->
                        Arrays.stream(users)
                                .filter(user2 -> user1 != user2)
                                .map(user2 -> new Pair(user1, user2))
                ).collect(toList());
    }

    @NotNull
    private static List<Pair> getPairs2(User[] users) {
        List<Pair> list = new ArrayList<>();
        for (User user1 : users) {
            for (User user2 : users) {
                if (user1 != user2) {
                    Pair pair = new Pair(user1, user2);
                    list.add(pair);
                }
            }
        }
        return list;
    }

    private static List<Pair> getPairs3(Iterable<User> users1, Iterable<User> users2) {
        return API.For(
                users1,
                users2
        ).yield((a, b) -> a == b ? Option.<Pair>none() : Option.of(new Pair(a, b)))
                .flatMap(it -> it)
                .toJavaList();
    }
}

最開始接觸Stream的人會發現f1的可讀性沒有那麼強,其實flatMap可以實現多層for迴圈以及不同層級的控制(如本例中的filter)。
若將f1轉換為f2的話,就一目瞭然了:方法生成了不同使用者間的配對。f1和f2兩者屬於不同的程式設計風格,實現了相同的效果。

for comprehension

flatMap 還可以實現將普通方法應用在容器類上實現拆包、列舉、過濾和生成結果序列。 若有函式f,其引數均為普通型別,而 for comprehension 可以將包裝類的結果取出,應用到函式上。

如 subtract(int a, int b):

  • a = Optional(1), b = Optional(2) => result = Optional(-1)
  • a = Optional(3), b = Optional.empty => result = Optional.empty

上例中的 getPairs3 函式, For comprehension 生成了使用者間的組合列舉:

  • users1: [u1, u2], users2: [u3, u4] => result = [(u1, u3), (u1, u4), (u2, u3), (u2, u4)]
  • users1: [], users2: [u1, u2] => result = []

java 中不提供 for comprehension 語法糖,我們可以自己實現,不過需要對於每種 monad 單獨編寫;或者使用現有的集合類vavr。
有時,對於複雜的 flatMap, 不妨直接回歸到原來的方法:外部迭代。

public class ForComprehensionDemo {
    @Value
    static class User {
        String id;
        String name;
        String mobile;
        Age age;
        Gender gender;

        public static User generateRandom() {
            Faker faker = new Faker();
            return new User(
                    faker.idNumber().valid(),
                    faker.name().name(),
                    faker.phoneNumber().cellPhone(),
                    rand(Age.class),
                    rand(Gender.class)
            );
        }
    }

    enum Gender {
        MALE, FEMALE;
    }

    enum Age {
        MIDDLE_AGE, YOUNG_ADULT;
    }

    @Value
    static class FindFriendRequest {
        Option<Gender> gender;
        Option<Age> age;
    }

    public static void main(String[] args) {
        FindFriendRequest request = new FindFriendRequest(Option.of(Gender.FEMALE), Option.of(Age.MIDDLE_AGE));
        Option<List<User>> friends = getFriends1(request);
        friends.forEach(list -> list.forEach(System.out::println));
    }

    // vavr 集合庫實現
    private static Option<List<User>> getFriends1(FindFriendRequest request) {
        return API.For(
                request.getGender(),
                request.getAge()
        ).yield(ForComprehensionDemo::searchInDb);
    }

    // 使用flatMap實現
    private static Option<List<User>> getFriends2(FindFriendRequest request) {
        Option<Gender> ts1 = request.getGender();
        Option<Age> ts2 = request.getAge();
        BiFunction<Gender, Age, List<User>> f = ForComprehensionDemo::searchInDb;
        return ts1.flatMap(t1 ->
                ts2.map(t2 ->
                        f.apply(t1, t2)
                )
        );
    }

    public static List<User> searchInDb(Gender gender, Age age) {
        return Stream.generate(User::generateRandom)
                .filter(user -> user.gender == gender && user.age == age)
                .limit(3)
                .collect(toList());
    }

    public static <T extends Enum<T>> T rand(Class<T> clazz) {
        T[] values = clazz.getEnumConstants();
        return values[new Random().nextInt(values.length)];
    }
}

jdk8 之後標準庫的補充

點評:能新增這些方法,基本上說明這些方法挺有用。可以透過編寫工具類或者使用Guava類庫等實現相同功能。

  • List::of 建立不可變集合
  • List::copyOf 淺複製,生成不可變集合
  • Stream::toList 建立不可變集合
  • Stream::takeWhile, dropWhile, iterate(seed, predicate, mapper) 更精確的流控制,便於對無限流的過濾
  • Stream::ofNullable 幫助避免判空。null在程式碼中就應該少用
  • Optional::stream 終於提供了 Optional 和 Stream之間的轉換,但是 Optional 和 Stream 還是沒有實現 Iterable 介面
  • Optional::or 很有用的方法,可以實現短路運算
	<dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
        </dependency>
        <dependency>
            <groupId>com.github.javafaker</groupId>
            <artifactId>javafaker</artifactId>
            <version>1.0.2</version>
        </dependency>
        <dependency>
            <groupId>io.vavr</groupId>
            <artifactId>vavr</artifactId>
            <version>0.10.4</version>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>31.1-jre</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-commons</artifactId>
            <version>2.6.10</version>
        </dependency>

相關文章