4、Kotlin集合与泛型:List、Set、Map、集合操作符、泛型基础、协变与逆变、星投影

好,咱们今天来聊聊 Kotlin 里两个绕不开的话题:集合和泛型。

说实话,这两个东西在 Android 开发里天天见。你写的每个列表、每个网络请求的返回数据,背后都离不开它们。我刚开始从 Java 转 Kotlin 时,最直观的感受就是——集合操作太爽了,但泛型那套协变逆变,确实让我头疼了好一阵子。

咱们一个一个来拆解。

4.1 集合三兄弟:List、Set、Map

Kotlin 的集合体系,说白了就是三个核心接口:ListSetMap。但和 Java 最大的区别是——Kotlin 把「可变」和「不可变」分得清清楚楚。

4.1.1 List:有序可重复

List 就是有序的集合,元素可以重复。我个人习惯用 listOf() 创建不可变列表,用 mutableListOf() 创建可变列表。

// 不可变 List
val fruits: List<String> = listOf("苹果", "香蕉", "橘子")
// fruits.add("西瓜") // 编译错误!不可变

// 可变 List
val mutableFruits: MutableList<String> = mutableListOf("苹果", "香蕉")
mutableFruits.add("西瓜") // 没问题

嗯,这里要注意:不可变不是说里面的对象不能修改,而是你不能增删元素。对象本身如果是可变的,你照样能改它的属性。

4.1.2 Set:无序不重复

Set 用来存不重复的元素。我记得有一次做用户标签系统,就用 Set 来去重,比手动写循环优雅多了。

val uniqueNumbers: Set<Int> = setOf(1, 2, 3, 2, 1)
println(uniqueNumbers) // 输出 [1, 2, 3]

你想想看,如果业务上需要保证元素唯一性,直接用 Set 就对了。底层实现上,HashSet 基于哈希表,LinkedHashSet 保留插入顺序,TreeSet 按自然顺序排序。

4.1.3 Map:键值对

Map 存的是键值对,键唯一,值可以重复。我在项目中经常用它来缓存数据,比如用户信息缓存:

val userMap: Map<Int, String> = mapOf(1 to "张三", 2 to "李四")
val mutableUserMap: MutableMap<Int, String> = mutableMapOf()
mutableUserMap[3] = "王五"

这里有个小细节:to 是中缀表达式,实际上创建了一个 Pair 对象。Kotlin 的语法糖确实让代码更简洁了。

4.2 集合操作符:让代码飞起来

这是 Kotlin 集合最吸引我的地方。Java 8 虽然也有 Stream,但 Kotlin 的操作符是内联函数,性能更好,写法也更自然。

我挑几个最常用的说说:

4.2.1 filter 和 map

filter 做筛选,map 做转换。这两个组合起来,基本能解决 80% 的集合处理需求。

val numbers = listOf(1, 2, 3, 4, 5, 6)
val result = numbers
    .filter { it % 2 == 0 }  // 筛选偶数
    .map { it * it }         // 平方
println(result) // 输出 [4, 16, 36]

我曾经接手过一个老项目,里面全是 for 循环加 if 判断,看得我头皮发麻。后来改成链式调用,代码量减少了一半,可读性也上来了。

4.2.2 flatMap 和 flatten

这两个用来处理嵌套集合。比如你有一个班级列表,每个班级有多个学生,你想拿到所有学生:

val classes = listOf(
    listOf("小明", "小红"),
    listOf("小刚", "小丽")
)
val allStudents = classes.flatMap { it }
println(allStudents) // 输出 [小明, 小红, 小刚, 小丽]

flatMapflatten 更灵活,因为它可以在展开的同时做转换。

4.2.3 groupBy 和 associate

groupBy 按条件分组,associate 转成 Map。我在做数据统计时经常用:

data class Person(val name: String, val age: Int)
val people = listOf(
    Person("张三", 20),
    Person("李四", 25),
    Person("王五", 20)
)
val grouped = people.groupBy { it.age }
println(grouped) // 输出 {20=[Person(name=张三, age=20), Person(name=王五, age=20)], 25=[Person(name=李四, age=25)]}

4.2.4 排序操作符

sortedBysortedByDescending 按指定字段排序:

val sortedPeople = people.sortedBy { it.age }
小提示:链式调用时,注意操作符的顺序。先 filter 再 map 比先 map 再 filter 效率高,因为减少了不必要的转换。

4.3 泛型基础:类型参数化

泛型,说白了就是「类型参数化」。你写一个类或函数时,不确定具体类型,那就用个占位符,让调用者来指定。

class Box<T>(val value: T)

val intBox = Box<Int>(123)
val stringBox = Box<String>("Hello")

Kotlin 的泛型在编译时会被擦除(和 Java 一样),但有一些技巧可以绕过。比如用 inline 函数加 reified 关键字:

inline fun <reified T> isType(value: Any): Boolean {
    return value is T
}

这个 reified 是我最喜欢的 Kotlin 特性之一。Java 里没法直接判断泛型类型,但 Kotlin 可以。我在写 Retrofit 的 Response 解析时,就用它来区分不同的返回类型。

4.4 协变与逆变:理解 PECS 原则

好,到了最烧脑的部分了。协变和逆变,说白了就是「父子类型关系在泛型中怎么传递」。

先看个例子:

open class Animal
class Dog : Animal()
class Cat : Animal()

如果 DogAnimal 的子类,那 List<Dog> 是不是 List<Animal> 的子类?

答案是:不一定。这取决于泛型参数的位置。

4.4.1 协变:out

out 关键字表示「只读」或「生产者」。比如 List<out T>,你只能从里面取数据,不能往里写。

val dogs: List<Dog> = listOf(Dog(), Dog())
val animals: List<Animal> = dogs // 协变,没问题

为什么?因为 List 只有 get() 方法,没有 add()。你从 List<Dog> 里取出来的东西,当成 Animal 用,完全合理。

4.4.2 逆变:in

in 关键字表示「只写」或「消费者」。比如 Comparable<in T>,你只能往里写数据,不能往外读。

val animalComparator: Comparator<Animal> = Comparator { a1, a2 -> 0 }
val dogComparator: Comparator<Dog> = animalComparator // 逆变,没问题

为什么?因为 Comparator 只有 compare(T, T) 方法,它消费 T 类型的数据。一个能比较所有动物的比较器,当然也能比较狗。

核心记忆法:PECS 原则——Producer Extends, Consumer Super。生产者用 out(协变),消费者用 in(逆变)。

4.5 星投影:* 通配符

星投影 * 相当于 Java 里的 ?,表示「不知道具体类型,但我知道它是某种类型」。

fun printList(list: List<*>) {
    for (item in list) {
        println(item)
    }
}

这里 List<*> 等价于 List<out Any?>。你只能读,不能写(除了 null)。

我在写一些通用工具类时经常用星投影。比如一个日志打印函数,不管传什么类型的列表,都能打印出来。

注意:星投影不是「任意类型」,而是「某个确定的未知类型」。你不能往 MutableList<*> 里添加元素,因为编译器不知道具体类型,无法保证类型安全。

4.6 实战建议

说了这么多,最后给几个实际开发中的建议:

  • 优先用不可变集合listOf()mapOf() 等。能减少很多意外修改的 bug。
  • 链式调用别太长:超过 3 个操作符就换行,每行一个操作符,方便调试。
  • 泛型边界用 where:如果泛型需要多个上界,用 where T : A, T : B 的写法。
  • 避免滥用星投影:能用具体类型就用具体类型,星投影会丢失类型信息。

我曾经在一个项目里,因为用了 MutableList<*> 然后试图往里加数据,结果编译报错,排查了半天才发现是泛型的问题。从那以后,我对泛型的理解就深刻多了。

好了,集合和泛型就聊到这里。这些东西看着理论性强,但用多了就会发现——它们就是 Kotlin 工程化的基石。下一章咱们聊聊协程,那才是真正让 Android 开发脱胎换骨的东西。