Day 3:Kotlin基础(二)

昨天已经把变量、类型、函数和字符串模板过了一遍,它们解决的是"数据怎么表示、逻辑怎么封装、结果怎么输出"的问题。但程序真正开始变得有用,往往是从控制流程开始的。用户输入不同,程序要给出不同反馈,数据不止一条,程序要重复处理,某个条件还没满足,程序就要继续等待或重试。这些都不是单靠变量声明能完成的。

今天要讲的 ifwhenforwhile,就是 Kotlin 里最基础也最常用的控制流工具。它们看起来只是几个关键字,但背后对应的是四种很典型的思维方式,判断一个条件、在多个分支里选择一个、遍历一组数据、在条件满足前持续执行。把这几种写法掌握住,后面写 Android 页面逻辑、处理列表数据、校验用户输入时,代码就不会只停留在"顺序执行"的阶段。

if:不只是一个语句

if 在 Kotlin 里仍然是最直接的条件判断工具。它接收一个布尔条件,条件为 true 时执行对应分支,条件不满足时可以交给 else。如果你以前写过 Java、JavaScript 或 C,这部分语法并不陌生,真正需要注意的是,Kotlin 里的 if 不只是"执行一段代码"的语句,它还可以作为表达式返回一个值。

这个设计会影响你写代码的方式。在 Java 里经常用三元运算符 condition ? a : b 给变量赋值,而 Kotlin 没有三元运算符,因为 if 表达式已经覆盖了这个场景。条件判断和赋值可以合在一起写,代码会更集中,也更容易看出"这个变量由哪几个分支决定"。

kotlin 复制代码
val score = 85
val result = if (score >= 60) "及格" else "不及格"
println(result) // 及格

上面这段代码里,if 会根据 score >= 60 的结果在两个字符串里选一个,最终 result 拿到的是 "及格"。这里的关键点不是少写了几行代码,而是 if 整体有了一个结果值。只要你把 if 放在赋值右侧,就要保证每一种情况都能产生结果,所以 else 不能省略。否则当 score < 60 时,表达式不知道该返回什么,编译器会直接报错。

这种约束看起来更严格,但对初学者很有帮助。它会逼着你在写判断时把"条件不成立怎么办"一起考虑掉,减少那种只处理成功分支、失败分支被忘在一边的代码。如果条件不止两个分支,可以继续接 else if,把判断按优先级一层层排下去:

kotlin 复制代码
val level = if (score >= 90) "优秀"
    else if (score >= 80) "良好"
    else if (score >= 70) "中等"
    else if (score >= 60) "及格"
    else "不及格"

多分支判断里,顺序很重要。上面的代码会先看是否大于等于 90,再看是否大于等于 80,越靠前的条件优先级越高。如果把 score >= 60 放到最前面,85 分会提前命中"及格",后面的"良好"就永远没有机会执行。写这类分数、等级、区间判断时,先想清楚条件之间是否有包含关系,再决定从大到小还是从小到大排列。

当每个分支的逻辑不止一行时,可以把分支写成代码块。代码块里前面的语句负责执行动作,最后一行表达式负责返回结果:

kotlin 复制代码
val description = if (score >= 90) {
    println("恭喜高分")  // 执行动作
    "表现优异"           // 这一行的值就是 if 的结果
} else {
    println("继续努力")
    "仍需提升"
}

这段代码体现了 Kotlin 里一个很重要的习惯,代码块({} 包裹的区域)本身也可以有值,值就是块内最后一行表达式的结果。println("恭喜高分") 只是执行过程,不会成为 description 的值,真正赋给 description 的是最后一行 "表现优异"。这个规则后面会在 whentry、lambda 里反复出现,提前熟悉它,读 Kotlin 代码会轻松很多。

也正因为代码块可以返回值,if 的两种用法要分清楚。如果 if 出现在赋值语句、函数返回值这类需要结果的地方,它就是表达式,必须覆盖所有分支。如果 if 单独出现,只是为了"条件满足时做一件事",那它就是普通语句,else 可以省略。前者关心结果值,后者关心副作用,这个区别会直接影响编译器是否要求你写完整分支。

when:多分支的最佳选择

if-else if 链适合两三个条件,继续往下堆就会变得笨重。每个条件都散在一行里,读代码的人要不断确认它们判断的是不是同一个变量,修改时也容易漏掉某个分支。when 就是 Kotlin 为多分支场景准备的工具,它的作用类似 Java 的 switch,但表达能力更强,可以匹配具体值,也可以匹配范围、类型,甚至可以不带主语,直接写一组布尔条件。

最基础的写法是给 when 一个主语,也就是括号里的 day。后面的每个分支都拿自己的条件和这个主语比较,匹配成功就执行右侧内容:

kotlin 复制代码
val day = 3
val dayName = when (day) {
    1 -> "周一"
    2 -> "周二"
    3 -> "周三"
    4 -> "周四"
    5 -> "周五"
    6 -> "周六"
    7 -> "周日"
    else -> "无效"
}
println(dayName) // 周三

这个例子里,when (day) 会依次拿 day123 等分支比较。因为 day 的值是 3,所以第三个分支命中,整个 when 表达式的结果就是 "周三"。和 if 一样,只要 when 被用来给变量赋值,它就必须保证每一种输入都有结果。要么你把所有可能都列出来,要么加一个 else 做兜底。

else 可以理解成最后一道保险。前面的分支都没匹配上时,程序不会停在半路,而是进入 else。在这个例子里,day 如果是 0、8 或其他不在 1 到 7 之间的数字,就会得到 "无效"。写业务代码时,这种兜底分支很重要,因为真实输入经常不像示例里那么干净。

如果多个输入最终要走同一套逻辑,可以把它们写在同一个分支里,用逗号隔开。这样做不是为了炫语法,而是为了让代码表达出真正的分类意图。比如一周里的 1 到 5 都属于工作日,6 和 7 都属于周末,这时我们关心的已经不是"今天具体是周几",而是"它属于哪一类":

kotlin 复制代码
val type = when (day) {
    1, 2, 3, 4, 5 -> "工作日"
    6, 7 -> "周末"
    else -> "无效"
}

这里的 1, 2, 3, 4, 5 可以理解成"只要 day 等于其中任意一个值,就执行右边的分支"。它不是创建集合,也不是做循环,只是 when 分支条件的一种简写。Kotlin 仍然会从上到下依次比较分支,匹配到第一个符合条件的分支后,就把这个分支右侧的表达式作为整个 when 的结果,后面的分支不会再继续判断。

分支右侧也不一定只能写一行字符串。如果同一类值需要先做一些处理,再返回结果,可以像 if 一样使用代码块。这个时候仍然遵守"最后一行作为结果"的规则,前面的 println 只是执行过程,最后的字符串才是赋给 message 的值:

kotlin 复制代码
val message = when (day) {
    1, 2, 3, 4, 5 -> {
        println("准备进入工作日流程")
        "工作日"
    }
    6, 7 -> {
        println("可以休息一下")
        "周末"
    }
    else -> "无效"
}

逗号分组适合离散值,比如星期、菜单编号、状态码这类可以一个个明确列出来的值。如果判断的是连续范围,继续用逗号会让代码变长,也容易漏掉边界,这时应该换成下面要讲的区间匹配。简单说,几个具体值归为一类,用逗号,一整段连续数字归为一类,用区间。

when 也可以不给主语。没有主语时,分支左侧就不再是拿来和某个值比较的候选项,而是一组独立的布尔表达式。程序会从上到下检查这些条件,第一个结果为 true 的分支胜出。这种写法特别适合替代复杂的 if-else if 链,因为它把所有条件整齐地排在同一个结构里:

kotlin 复制代码
val score = 85
val grade = when {
    score >= 90 -> "A"
    score >= 80 -> "B"
    score >= 70 -> "C"
    score >= 60 -> "D"
    else -> "F"
}

注意这里的判断顺序。条件从上到下依次求值,第一个满足的生效,后面不再判断。因此条件写的顺序很关键,如果把 score >= 60 放在第一行,85 分会直接匹配到 "D",而不会再走到后面的 "B" 行。像分数评级这种条件互相包含的场景,通常要把更严格、更具体的条件放在前面。

除了一个个写具体值,when 也可以配合 in!in 判断值是否落在某个范围或集合里。范围判断在处理数字区间时很常见,比如年龄段、分数段、页码范围、库存数量等。和无主语 when 一样,如果多个范围之间可能重叠,就要认真安排顺序,保证先命中的是你真正想要的分支:

kotlin 复制代码
val x = 15
when (x) {
    in 1..10 -> println("个位数范围")
    in 11..20 -> println("11 到 20 之间")
    !in 1..20 -> println("超出范围")
}

.. 创建的是包含两端点的闭区间,1..10 表示 1 到 10 的所有整数,1 和 10 都算在里面。!in 1..20 则表示"不在 1 到 20 这个范围内"。如果不想包含终点,可以用 until1 until 10 表示 1 到 9。逆序范围用 downTo,例如 10 downTo 1 代表 10、9、8......一直到 1。这些区间操作符不只服务于 when,在下一节的 for 循环里会更常见。

when 还经常和 is!is 一起做类型检查。这个能力在真实项目里很实用,因为很多函数接收到的参数一开始可能是一个比较宽泛的类型,比如 Any、接口类型或者父类类型。进入不同分支后,我们希望按具体类型处理它,而不是到处手动强制转换:

kotlin 复制代码
fun describe(input: Any): String = when (input) {
    is Int -> "整数:${input + 1}"         // 自动转为 Int,可以直接运算
    is String -> "字符串长度:${input.length}" // 自动转为 String,可以调 .length
    is Boolean -> "布尔值:${!input}"
    else -> "未知类型"
}

这段代码里,input 的声明类型是 Any,按理说它不一定有 length 属性,也不一定能做加法。但进入 is Int 分支后,Kotlin 编译器已经知道这里的 inputInt,所以可以直接写 input + 1,进入 is String 分支后,也可以直接访问 input.length。这就是 Kotlin 的智能类型转换(smart cast)。它减少了很多显式强转,让类型判断之后的代码更接近"按真实类型写逻辑"。

for:遍历一切可迭代的东西

讲完判断和分支,就该看重复执行了。for 循环适合处理"我有一组东西,要挨个处理"的场景。在 Kotlin 里,for 通过 in 关键字把循环变量和可迭代对象连起来,格式是 for (变量 in 可迭代对象) { ... }。这里的可迭代对象可以是区间、数组、列表、Map,也可以是任何提供迭代器的对象。

遍历数字区间是最容易理解的用法。循环开始后,i 会依次拿到区间里的每一个值,循环体也会跟着执行同样次数:

kotlin 复制代码
for (i in 1..5) {
    print("$i ") // 输出:1 2 3 4 5
}

.. 创建的区间包含两端,也就是 1 到 5 都会走一遍。如果只需要 1 到 4,不想包含 5,就用 until。这种半开区间在编程里很常见,因为很多集合下标都是从 0 开始,到 size - 1 结束:

kotlin 复制代码
for (i in 1 until 5) {
    print("$i ") // 输出:1 2 3 4
}

until 在遍历数组或列表下标时特别顺手。假设一个列表有 5 个元素,合法下标是 0、1、2、3、4,而不是 1 到 5。写成 0 until list.size 正好覆盖所有合法下标,不需要自己写 list.size - 1,也减少了边界写错的机会。

如果处理顺序要反过来,比如倒计时、从最后一页往前翻、从高分到低分检查,就可以用 downTo 做逆序遍历:

kotlin 复制代码
for (i in 5 downTo 1) {
    print("$i ") // 输出:5 4 3 2 1
}

无论正序还是逆序,默认步长都是 1。也就是说,每次循环变量只前进或后退一个单位。如果想隔一个取一个、每三个取一个,就可以用 step 调整步长:

kotlin 复制代码
for (i in 1..10 step 2) {
    print("$i ") // 输出:1 3 5 7 9
}
// step 也可以和 downTo 配合
for (i in 10 downTo 0 step 3) {
    print("$i ") // 输出:10 7 4 1
}

除了数字区间,for 更常见的用途是遍历集合。遍历列表或数组时,最自然的写法是直接拿元素本身,不必先拿下标再取值。只要循环体里不关心当前位置,这种写法通常最清楚:

kotlin 复制代码
val fruits = listOf("苹果", "香蕉", "橙子")
for (fruit in fruits) {
    println(fruit)
}

如果循环体里既需要元素本身,又需要它在列表里的位置,就要把下标也拿出来。第一种写法是用 indices 属性得到所有合法索引,再通过下标访问元素。这种方式比较接近传统写法,适合你确实需要用索引做额外计算的情况:

kotlin 复制代码
for (i in fruits.indices) {
    println("第 ${i + 1} 个:${fruits[i]}")
}

另一种写法是用 withIndex()。它会在遍历时同时给出下标和值,避免在循环体里反复写 fruits[i],读起来更像是在直接描述"我需要当前位置和当前元素":

kotlin 复制代码
for ((index, fruit) in fruits.withIndex()) {
    println("第 ${index + 1} 个:$fruit")
}

(index, fruit) 这个写法叫解构声明,它会把 withIndex() 返回对象里的两个字段同时拆出来,分别赋给 indexfruit。第一次见到可能会觉得像语法糖,但它解决的是一个很实际的问题:当一个对象本来就由几个固定部分组成时,可以直接把这些部分命名出来,而不是先拿到整个对象再逐个访问。遍历 Map 时,这种写法尤其常见:

kotlin 复制代码
val scores = mapOf("Tom" to 90, "Jerry" to 85, "Spike" to 78)
for ((name, score) in scores) {
    println("$name -> $score")
}

遍历 Map 时,for 每次拿到的其实是一个 Map.Entry,里面包含 keyvalue。用解构声明后,可以直接把它们拆成 namescore,循环体里的代码会更贴近业务含义,不用反复写 entry.keyentry.value

循环不一定每次都要从头走到尾。遇到目标值后,可以用 break 提前终止当前循环,遇到当前这条数据不需要处理时,可以用 continue 跳过本轮剩下的代码,直接进入下一轮。这两个关键字在 forwhiledo-while 中行为一致,后面的猜数字练习会用 break 作为退出无限循环的出口。

while:当你不确定要循环多少次

for 适合循环次数相对明确的场景,比如遍历一个列表、走完一个数字区间、处理 Map 里的每一项。但有些循环在开始前并不知道会执行多少次,只知道"某个条件还没满足,就继续做"。这种场景更适合 while,比如一直让用户输入直到格式合法,一直尝试连接服务器直到成功,或者一直掷骰子直到掷出 6。

while 的执行方式很直接,先检查条件,条件为 true 才执行循环体,执行完一轮后,再回到条件处重新检查。只要条件仍然为 true,循环就会继续,一旦条件变成 false,循环结束,程序继续往下走:

kotlin 复制代码
var count = 0
while (count < 3) {
    println("计数:${++count}")
}
// 输出:
// 计数:1
// 计数:2
// 计数:3

这个例子里,count 从 0 开始,每次进入循环都会先自增再打印。++count 是前置自增,含义是先加一再使用新值,如果写成 count++,第一次输出会是 "计数:0",因为它会先使用当前值,再把变量加一。在循环条件刚好踩着边界时,前置和后置自增的区别可能导致多一轮或少一轮,排错时可以优先检查这里。

do-whilewhile 的核心差别在于检查条件的时机。while 是先判断,再决定要不要执行,do-while 是先执行一次循环体,再判断要不要继续。因此,do-while 的循环体至少会执行一次,无论条件一开始是真还是假:

kotlin 复制代码
var num = 10
do {
    println("当前值:$num")
    num--
} while (num > 5)
// 输出:10 9 8 7 6

在上面这个例子里,num 初始值是 10,本来就满足 num > 5,所以换成普通 while 也能得到类似结果。真正能看出差异的是条件一开始就是 false 的情况。普通 while 会直接跳过循环体,而 do-while 会先执行一次再说:

kotlin 复制代码
var x = 5
while (x > 5) {
    println("while 执行了") // 不会输出
}

do {
    println("do-while 执行了") // 会输出这一行
} while (x > 5)

这个"至少执行一次"的特性在实际项目里很常见。用户输入校验就是典型场景,程序总要先提示用户输入一次,然后才能判断输入是否合法,如果不合法,再进入下一轮。重试逻辑也类似,程序通常要先尝试一次操作,比如发请求、读文件、连接设备,然后根据结果决定是否继续重试。判断循环类型时,可以先问自己一个问题,循环体有没有必要无条件先执行一次?如果有,do-while 往往更自然,如果没有,普通 while 更清楚。

练习:猜数字游戏

第一个练习把今天学的 ifwhenwhile 串起来,做一个控制台猜数字游戏。程序在 1 到 100 之间随机生成一个数字,让用户反复猜,每次猜完给出"大了"或"小了"的提示,猜中后根据尝试次数给评语。

kotlin 复制代码
import kotlin.random.Random

fun main() {
    val target = Random.nextInt(1, 101) // 1 到 100 的随机整数
    var guessCount = 0
    var guess: Int

    println("已生成 1~100 之间的随机数,来猜吧。")

    // 用户输入可能不是数字,所以用无限循环 + break 控制退出
    while (true) {
        print("请输入你猜的数字:")
        val input = readlnOrNull() ?: continue // 空输入直接重试
        guess = input.toIntOrNull() ?: run {
            println("请输入有效的整数。")
            continue
        }

        guessCount++

        // if 表达式直接返回提示文字
        val hint = if (guess < target) "太小了,再大一点。"
            else if (guess > target) "太大了,再小一点。"
            else ""

        when {
            hint.isNotEmpty() -> println(hint) // 还没猜对,继续循环
            else -> break // 猜对了,跳出循环
        }
    }

    // 根据尝试次数分段评语
    val comment = when (guessCount) {
        1 -> "一发入魂!你是神仙吧?"
        in 2..5 -> "厉害,${guessCount} 次就猜中了。"
        in 6..10 -> "还行,${guessCount} 次猜中,下次加油。"
        else -> "${guessCount} 次才猜中,再练练范围判断吧。"
    }
    println(comment)
}

这段代码中 Random.nextInt(1, 101) 的第二个参数是上界(exclusive),所以 1..100 的随机数要写成 nextInt(1, 100 + 1)readlnOrNull() 从控制台读取一行,返回可空类型 String?,用 ?:(Elvis 操作符)给空输入一条兜底动作。toIntOrNull() 同样返回可空类型------转换失败时是 null,和 run {} 的组合起到了"转换失败则提示并重试"的效果。这些类型安全的写法背后是 Kotlin 的空安全体系,后面会有专门的一篇文章展开。

while (true) 创建了一个无限循环,唯一的出口是猜中后执行的那句 break。这个模式的优点是循环退出条件放在循环体中间而非顶部,读代码的人顺着执行流看到 break 就知道"到这里就结束了",比在 while 的条件里塞一个 guess != target 的判断更直观,因为 guess 要在循环体里经过多次赋值才等于 target,条件写死在括号外面反而割裂了退出点和判断逻辑。

游戏最后根据猜测次数用 when 做评语分段,when 的不同分支里都通过字符串模板嵌入了 guessCount 的值,保证用户在最终输出里能同时看到次数和评价。

练习:成绩评级系统

第二个练习做一个学生成绩评级系统,输入一个 0 到 100 的分数,先合法性校验,再用 when 映射到五级等级,最后用 for 列出所有学生的成绩。

kotlin 复制代码
fun getGrade(score: Int): String = when (score) {
    in 90..100 -> "A(优秀)"
    in 80..89 -> "B(良好)"
    in 70..79 -> "C(中等)"
    in 60..69 -> "D(及格)"
    in 0..59 -> "F(不及格)"
    else -> "无效分数" // 兜底,负数或超过 100
}

fun main() {
    val students = mapOf(
        "张伟" to 92,
        "李娜" to 78,
        "王磊" to 45,
        "赵敏" to 88,
        "刘洋" to 61,
        "陈静" to 100
    )

    // for 遍历 Map,解构出 name 和 score
    for ((name, score) in students) {
        val grade = getGrade(score)
        // 用字符串模板对齐输出------`padEnd(4)` 让名字列宽度一致
        println("${name.padEnd(4)}: ${score.toString().padStart(3)} 分 -> $grade")
    }

    // 用 for 遍历区间统计各等级人数
    val gradeRanges = listOf(
        "A" to (90..100),
        "B" to (80..89),
        "C" to (70..79),
        "D" to (60..69),
        "F" to (0..59)
    )
    println("\n--- 等级分布 ---")
    for ((label, range) in gradeRanges) {
        // count { } 遍历 students 的每个 value,统计其中落在当前区间内的个数
        val count = students.values.count { it in range }
        println("$label 等级:$count 人")
    }
}

getGrade 函数体直接用等号连接 when 表达式,省略了花括号和 return。分数上限是 100,所以每个区间的上限也都写到了 100,代码读起来没有"为什么 A 要 90...100 而不是 90...99"这种疑问。

padEnd(4) 给名字右边填空格到 4 个字符宽,padStart(3) 给数字左边填空格到 3 个字符宽。两个方法配合后输出是这样:

复制代码
张伟  :  92 分 -> A(优秀)
李娜  :  78 分 -> C(中等)
王磊  :  45 分 -> F(不及格)
赵敏  :  88 分 -> B(良好)
刘洋  :  61 分 -> D(及格)
陈静  : 100 分 -> A(优秀)

如果名字是三个字,"张伟"后面补了两个空格,"李娜"也补了两个空格------padEnd 能保证所有中文字符宽度一致。注意这两个方法返回的是新字符串,不会修改原来的值,跟昨天学的字符串不可变特性吻合。

最后的统计段落里,students.values 拿到所有成绩,count { it in range } 遍历每个成绩并计数其中落在当前区间的个数。这里的 count 是一个高阶函数------接收一个 lambda 表达式作为判断条件,返回满足条件的元素个数。这种把函数当作参数传递的写法在 Kotlin 的标准库里大量存在,能显著减少手写循环的次数。

这个练习覆盖了今天的所有知识点,if 表达式的判断逻辑,when 的区间匹配和兜底分支,for 遍历 Map 和解构声明,while 的思路(虽然练习里没有直接出现,但成绩评级系统输入校验的部分完全可以补上 while 循环做重复输入)。把这两个练习的代码自己敲一遍,改改数字和条件玩一玩,Kotlin 的控制流就完全掌握了。