4、Kotlin集合与泛型:List、Set、Map、集合操作符、泛型基础、协变与逆变、星投影
好,咱们今天来聊聊 Kotlin 里两个绕不开的话题:集合和泛型。
说实话,这两个东西在 Android 开发里天天见。你写的每个列表、每个网络请求的返回数据,背后都离不开它们。我刚开始从 Java 转 Kotlin 时,最直观的感受就是——集合操作太爽了,但泛型那套协变逆变,确实让我头疼了好一阵子。
咱们一个一个来拆解。
4.1 集合三兄弟:List、Set、Map
Kotlin 的集合体系,说白了就是三个核心接口:List、Set、Map。但和 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) // 输出 [小明, 小红, 小刚, 小丽]
flatMap 比 flatten 更灵活,因为它可以在展开的同时做转换。
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 排序操作符
sortedBy、sortedByDescending 按指定字段排序:
val sortedPeople = people.sortedBy { it.age }
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()
如果 Dog 是 Animal 的子类,那 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 类型的数据。一个能比较所有动物的比较器,当然也能比较狗。
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 开发脱胎换骨的东西。