第074篇 区间与遍历:until、downTo 与 step

简介: Kotlin区间与遍历深度解析:`1..10` 创建`IntRange`对象(有内存开销),而`until`/`downTo`/`step`等被编译器优化为零对象计数循环;推荐`0 until size`替代`0..size-1`,避免空集合越界;`downTo`需显式`step -1`防死循环;区间仅用于索引,勿`map`/`filter`滥用。

区间与遍历是 Kotlin 里"藏得最深"的改动之一:Java 只有一个 for 关键字做这件事,Kotlin 拆成了 for、until、downTo、step、repeat、区间对象。这一拆带来了命名即语义的收益(for (i in 1 until 10) 一眼看出不含上界),也带来了一个必须说清的东西——区间在内存里的代价。这题的正确答法不是"for 有哪些写法",而是"这些写法背后是一个什么对象"。

先把结论放在前面:Kotlin 的 1..10 编译成一个 IntRange 对象(实现了 ClosedRange、Iterable),它是有内存成本的——在循环里反复创建区间会产生对象与装箱。而 for (i in 1 until 10)、for (i in 10 downTo 1)、for (i in 0 until n step 2)、repeat(n) { } 都会被编译器优化成不创建区间对象的纯计数循环。理解"哪种写法会创建对象"是这题的核心。

机制拆解

1..10 调用的是 Int.rangeTo(other: Int): IntRange,返回一个 IntRange——它内部只存两个 int(start、endInclusive)和一个 step 字段,不是数组,这一点比 Java 的 new int[10] 省得多。但它仍然是一个对象,且因为 IntRange 实现了 Iterable<Int>,for 循环遍历它时如果走的是迭代器路径,next() 返回的是 Integer 类型的装箱值。

优化发生在哪:until、downTo、step 这几个是编译器内建识别的。当 for 循环的源是一个由 until/downTo/rangeTo 直接产生的区间、且循环体没有修改循环变量、也没有非本地跳转(break/continue 走的是内建路径)时,编译器把它降级成 int i = start; i < end; i++ 这样的机器级循环,完全不创建对象。反之,如果把区间赋给一个变量再遍历(val r = 1..10; for (i in r)),优化就可能不生效,产生对象与迭代器开销。

repeat(n) { i -> } 同样被优化成计数循环,它的好处是不需要外部变量,i 就是参数,天然不可被意外修改。forEach 与 forEachIndexed 是补充:forEach 拿不到下标,forEachIndexed 能拿到,适合需要下标但不想写 for 的场景。

downTo 有一个隐蔽的坑:1 downTo 10 的语义是 1, 0, -1, ..., 10——只要起点和终点关系相反,就会一直递减下去(step 默认 1,不是 -1),循环次数等于 |1 - 10| + 1。所以当两个值来自动态计算时,downTo 可能产生一个几十亿次的死循环。修法是显式写 step:(1 downTo 10 step -1)。

这些坑的正确绕法

最常见的坑是用 0..size-1 而不是 0 until size,边界差一错误反复出现。 表现是集合为空时抛 IndexOutOfBoundsException,或集合最后一个元素被处理两次。这类 bug 在集合长度恒大于 0 的场景不会暴露,只在空集合时触发,所以测试常常漏掉。根因是 0..size-1 需要在 size 为 0 时得到 0..-1——Kotlin 里这个区间是非空的(包含 0),于是会真的去访问 list[0]。修法:默认用 until;确实需要含上界时写 0..lastIndex 并确保 isNotEmpty()。

其次是动态区间用错方向,downTo 造成死循环。 表现是某个筛选逻辑在特定数据下卡死,堆栈停在循环里;或者批量处理耗时突然变长几十倍。根因是 downTo 的默认步长是 +1,所以起点小于终点时它会一路减下去。修法是方向由数据决定时,显式 step(a downTo b step -1),或者在进入循环前用一次比较决定用哪个区间。

还有一个更隐蔽的坑:把区间当集合用,触发意料外的内存与时间开销。 表现是对一个 1..1_000_000 的区间做 map/filter 后 toList,直接 OOM;或者对区间用 contains 做大量二分之外的线性查找。根因是区间是 Iterable,map/filter 会真的逐个装箱成 Integer 并生成新集合。对策是:区间只用于索引与计数,需要"把区间内容当数据用"时,改用 IntArray 并配合下标操作,或直接用序列(asSequence())避免中间集合。

代码里见真章

看一段能直接跑的代码,把上面的机制落到具体写法上:

val list = listOf("a", "b", "c")

// 1) 推荐:until,不含上界;空集合安全
for (i in 0 until list.size) {
    print(list[i]) }

// 2) 反例:0..size-1 在 size==0 时区间仍包含 0 → IndexOutOfBounds
// for (i in 0..list.size - 1) {
    list[i] }        // 集合为空时会崩
val safe = if (list.isNotEmpty()) 0..list.lastIndex else null   // 确实需要含上界时

// 3) 倒序:方向确定
for (i in list.indices.reversed()) print(list[i])
for (i in 2 downTo 0) print(i)                 // 起点 > 终点时才安全

// 4) 步长
for (i in 0 until 10 step 2) print(i)          // 0,2,4,6,8
for (i in 10 downTo 0 step -2) print(i)        // 方向 + 步长都显式,最稳
// for (i in 1 downTo 10) — 危险:默认 step 是 +1,会一路减到 10 以下

// 5) repeat:不需要外部变量,i 不可被外部修改
repeat(3) {
    i -> print(i) }
// repeat 需要 Int 参数,count 要是变量则用 for ((i) in 0 until count)

// 6) forEach / forEachIndexed:集合遍历的函数式写法
list.forEach {
    print(it) }
list.forEachIndexed {
    i, v -> print("$i=$v") }   // 需要下标时

// 7) 区间是对象,别当数据用
// 危险:Int 装箱 + 中间集合,大区间直接 OOM
val bytes = (1..1_000_000).map {
    it.toByte() }.toList()
// 安全:直接构造数组,或用序列避免中间集合
val arr = ByteArray(1_000_000) {
    it.toByte() }

这段代码值得盯三处:第一处,0..size - 1 的反例旁边那行注释,直接写清了"空集合时崩"这个最容易被测试漏掉的原因;第二处,downTo 的危险写法与 step -2 的安全写法并列,把"方向 + 步长都要显式"这条纪律落到了代码上;第三处,最后两个集合构造的对比,说明了区间什么时候能当数据用。

这题在面试里怎么问、怎么答

"Kotlin 的 for 循环有性能问题吗?"答:绝大多数场景没有,因为 until/downTo/step 的 for 会被编译成计数循环,不创建区间对象、不装箱。真正有成本的是两种情况:①把区间先赋给变量再遍历(可能失去优化);②把区间当集合用(map/filter/toList),会逐个装箱并生成中间集合。

"1..10 是什么类型?"答:IntRange,是 IntProgression 的子类,实现了 ClosedRange<Int> 与 Iterable<Int>,内部只存 start/endInclusive/step 三个字段,不持有数组。Long 区间对应 LongRange,字符区间是 CharRange。

"为什么推荐 until 而不是 ..size - 1?"答:两个理由。①语义:until 表达"到边界之前",与 size 天然配合,不需要减一;②正确性:0..size-1 在 size 为 0 时生成非空区间导致越界,而 0 until 0 是空区间,循环体不执行。until 在空集合场景下是天然安全的,这是它被推荐的真正原因。

"repeat 和 for 有什么区别?"答:repeat 只能用于整数计数(没有区间、没有自定义步长),但它有两个优势——不需要外部循环变量(i 是参数,不会被意外修改)、语义更清晰("重复做 N 次")。Android 的 Compose 里 items(count) { i -> } 也是这个思路。

"使用中遇到过什么问题?"案例一:列表页适配代码用 0..data.size - 1,在接口返回空列表的首次进入时崩溃;修复为改用 0 until data.size。案例二:某个批量导出用 start downTo end,start/end 由参数传入,多数情况正常,某次 start < end 时进程卡死;修复为显式加 step -1 并在函数入口校验。

再补一个工程上值得讲清的一点:Android 列表遍历的四种写法应该按场景固定下来,形成团队约定。 建议的分工是:索引下标场景一律 for (i in 0 until list.size)(意图明确、空安全);需要同时拿元素与下标时用 forEachIndexed;只要元素时用 forEach;固定次数用 repeat。关键不是选哪一种,而是"同一个项目里只用一种"——混用会让代码风格割裂,review 时也无法用统一规则检查(比如"禁止 0..size-1"这条规则只有在你统一用 until 时才有意义)。可以在 lint 里加自定义规则或在 code review 清单里写上这条。

给正在准备面试的你

把这题画成一张"编译路径分叉"图:起点写 for (i in 1 until 10),向右拉一条粗箭头标"编译器识别为计数循环 → 零对象、零装箱",继续向右到 int i = 1; i < 10; i++ 的伪代码。分叉向下拉一条细箭头,标"先把区间赋给变量 → 走 Iterable 协议 → 创建 IntRange + 迭代器 + Integer 装箱"。两条路末端对比标注"性能差异是否可感知:多数场景不可感知,但大区间 + 集合操作时差距明显"。图下方补一个"边界"小图,画 0 until 0(空区间,循环不执行)与 0..-1(非空,含 0,访问越界)的对比。这张图能完整回答"for 循环有没有性能问题"和"为什么用 until"。

再补工程案例与踩坑——应用落点是全项目搜 0..size-1 与不带 step 的 downTo 逐个改成 until 或显式步长,把列表遍历统一成四种写法之一,并在 code review 清单里加入"禁止 ..size-1"这条。

复习时别孤立刷题:字符串模板与原生字符串——repeat(n) { i -> ... } 里的 i 是参数而非循环变量,这正是 repeat 相比 for 的安全性来源。

划两句重点:区间是对象(IntRange 只存三个字段不存数组),until/downTo/step 的 for 被优化成计数循环;默认用 0 until size,0..size-1 在空集合时越界;downTo 默认步长是 +1,方向反了会死循环;区间别当集合用(装箱 + 中间集合)。

下一篇聊 when 表达式:比 switch 强在哪里——沿着今天这条主线继续往前走。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。

「Android软件开发面试·从入门到精通」连载系列

上一篇:字符串模板与原生字符串:多行文本的正确姿势

下一篇预告:when-表达式:比-switch-强在哪里

有任何问题欢迎在评论区留言交流。

相关文章
|
1天前
|
存储 Java Android开发
第111篇 Kotlin Multiplatform 初识:跨端共享业务逻辑
KMP是JetBrains推出的跨平台框架,核心价值在于共享业务逻辑(domain/data层),而非UI。通过`expect/actual`机制,同一份Kotlin代码可编译至Android、iOS、桌面、Web等平台,提升复用效率,降低维护成本。
24 2
|
1天前
|
JavaScript 算法 Java
第120篇 tailrec 与尾递归:递归优化的编译期支持
`tailrec` 是 Kotlin 的编译期优化机制:当函数满足尾调用条件时,编译器将其重写为循环,避免栈溢出。它不依赖 JVM(JVM 无 TCO),而是 Kotlin 自主实现;不支持 `suspend`、非尾位置调用或需回溯的场景。本质是“递归定义 → 累加器循环”的转换。
21 2
|
1天前
|
Android开发
第122篇 Activity 启动模式:standard 到 singleInstance
本节详解Activity启动模式,直击“最容易出玄学bug”的栈管理机制。核心指出:行为由**系统、任务栈、launchMode、Intent flags四者共同决定**,仅记四个枚举值远远不够。重点剖析四种任务栈本质及flags优先级,并给出通知跳转、登录回退、启动图等真实场景的避坑方案与代码范式。
15 1
|
1天前
|
Java 定位技术 开发工具
第059篇 建造者模式:链式调用为何无处不用在哪些地方
建造者模式核心是**将复杂对象的创建过程外置、分步可控、集中校验**。它解决参数过多、互斥依赖、分步初始化问题,关键在于:私有构造、链式setter、build()统一校验、产物不可变(final+防御拷贝)、默认值内聚于Builder——非仅为语法糖,而是工程化构造控制。
27 1
|
1天前
|
JSON Java API
第082篇 reified 泛型:擦除限制的破局者
`reified` 依赖 `inline` 的根本原因是 JVM 泛型擦除——运行期无法获取 `T` 的类型信息。`reified` 并非“恢复”泛型,而是在编译期借助内联,将调用点的实际类型(如 `String`)直接代入函数体,使 `x is T`、`T::class.java` 等操作合法。它仅适用于编译期已知类型,不支持运行时动态类型,且会增加代码体积。工程中应优先使用 AndroidX 官方显式类型 API,必要时提供 `Class&lt;T&gt;` 重载以兼顾灵活性与兼容性。
11 0
|
1天前
|
缓存 API Android开发
第125篇Fragment 通信演进:从接口回调到共享 ViewModel
本文梳理Fragment通信演进史,揭示五代方案(接口回调、setFragmentResult、广播、共享ViewModel、Fragment Result API)的耦合本质。核心结论:**仅推荐三种场景化方案——Fragment Result API传一次性结果、共享ViewModel管共同状态、事件总线限于跨模块解耦**。重点剖析“耦合藏在哪”,并指出`viewLifecycleOwner`误用、事件重复消费、ViewModel状态污染等高频坑及自动化防治策略。
19 0
|
1天前
|
缓存 安全 编译器
第115篇 卫语句与早返回:可读性重构实战
本文详解Kotlin中“卫语句与早返回”这一关键编码纪律:通过`if`守卫、Elvis(?:)、`let`、`require`/`check`等机制,将异常路径前置处理,使正常逻辑平铺于外层,显著降低嵌套深度与认知负担。强调“守卫在前、主线平铺、缩进不超三层”,并厘清契约校验层次与常见误用陷阱。
14 0
|
1天前
|
编译器 Android开发 C++
第100篇 密封类加 when 的状态机:ViewModel 状态范式
本节聚焦“状态组织”,详解 Kotlin `sealed` 类建模状态机的三大原则:①状态完备(编译期穷举检查);②状态互斥(杜绝矛盾态);③迁移可追踪(单入口变更)。对比枚举与多布尔字段,结合登录、表单、列表等真实场景,阐明如何用 `sealed interface/class` + `when` 穷举 + 分离 StateFlow/SharedFlow,构建健壮、可维护的 UI 状态流。
17 0
|
1天前
|
传感器 Java 测试技术
第095篇 Flow 操作符进阶:debounce 与 combine
本节深入剖析 Flow 常用操作符的工程本质,聚焦面试高频痛点:为何“代码能跑却线上出错”。按转换、组合、副作用、控制四类梳理,并透彻讲解 `flatMapLatest/merge/concat` 区别、`catch` 作用域、`flowOn` 影响范围等关键原则,辅以搜索防抖、轮询重试等真实落地范式。
20 0
|
1天前
|
安全 Java 编译器
第064篇 空安全入门:?、!! 与 ?. 的三分天下
Kotlin空安全是编译期类型契约:`T`与`T?`本质不同。`?.`链式短路不求值,`?:`右值懒执行,`?.let`中`it`为非空类型,`!!`仅限框架保证场景。核心原则——null是需显式处理的分支,可空性应在数据入口收敛。
22 0

热门文章

最新文章