首页 / 资讯中心 / 文章详情

Java基础语法进阶:流程控制、数组与方法实战

Java基础语法进阶:流程控制、数组与方法实战 ★ FEATURED ARTICLE
很多刚开始学 Java 的人在学完变量、数据类型和运算符之后会遇到一个明显的卡点单个知识点都懂但一让你写个判断、写个循环、把一段逻辑拆成方法脑子就空了。这篇 Java 基础语法第二部讲的就是把这些“散装语法”拼成能跑起来的程序的那部分内容——流程控制、数组、方法以及它们怎么组合起来解决一个具体问题。它解决的问题很直接让代码从“一行一行往下执行”进化成“会判断、会重复、会复用”。适合已经能写出 Hello World、知道 int 和 String 区别、但独立写个小功能还发怵的朋友也适合工作几年后回头补基本功的人因为这一篇里藏着不少面试和实际编码中反复出现的细节坑。1. 为什么基础语法非得拆成两篇来讲1.1 从“能写”到“能跑通逻辑”的分水岭第一部分讲的是“零件”变量是盒子数据类型决定盒子装什么运算符决定盒子之间怎么运算。这部分内容的特点是线性的你从上往下读写出来的代码也是一行一行往下走不会拐弯。但真实程序几乎不会这么老实它要判断用户输入对不对要遍历一批数据要把重复逻辑抽出来复用。这就是分水岭所在语法本身不难难的是把语法组织成控制流。我见过不少人卡在这一步的真实原因不是记不住 for 循环的写法而是不清楚“什么时候该用循环、什么时候该用方法”。举个例子让你求 1 到 100 的和你会写循环但让你求 1 到 100 里所有偶数的和有人就开始犯迷糊其实只是把条件判断塞进循环体而已。这种“语法组合能力”必须靠这一篇里的内容建立起来光背语法条目没用。还有一层现实原因流程控制、数组、方法这三块是后面学面向对象、集合、异常处理的直接地基。数组理解不透后面学 ArrayList 就只能在表面打转方法参数传递的机制没搞明白后面调 API 时出现的各种“为什么我改了对象它没变/变了”的问题就永远解释不清。所以这一篇的位置很关键它是承上启下的那一环。1.2 这一篇的覆盖范围与建议的学习节奏我把这一篇的内容按“判断与重复 → 批量数据 → 逻辑复用 → 综合实战 → 排错”的顺序来组织不是随便排的。流程控制解决的是“程序怎么走”数组解决的是“一堆同类数据怎么存”方法解决的是“一段逻辑怎么复用”三者是递进关系你先得会控制流程才写得出数组的遍历你先得会遍历数组才写得出处理数组的方法。学习节奏上我的建议是每一块先看懂原理然后立刻动手敲一遍不要只看不写。尤其是数组和方法这两块看代码觉得“就这”自己写的时候各种小错不断。我个人的习惯是每学一个语法点就逼自己想一个生活场景把它用出来比如用 switch 写个菜单、用二维数组存个座位表、用方法把成绩统计拆开。这种“主动找场景”的练习方式比刷一百道选择题管用得多。注意这一篇里的代码示例都可以直接复制到你的 IDE 里跑。建议你每看完一段就先自己猜一下输出结果再运行验证猜错了正好是查漏补缺的机会。2. 流程控制让代码学会做判断和重复2.1 if-else 的三种写法和判断边界if 是所有流程控制的起点但很多人只掌握了最基础的一种写法。实际编码里它有三种常见形态各有适用场景。第一种是单分支if (条件) { ... }适合“满足就做不满足就算了”的场景比如参数校验后直接 return。第二种是双分支if (条件) { ... } else { ... }适合二选一。第三种是多分支if ... else if ... else适合区间判断比如成绩分级。这里有个容易被忽略的细节多分支的执行顺序是从上往下匹配一旦命中就跳出整个结构。这意味着条件的排列顺序会影响结果。比如你先判断score 60输出及格再判断score 90输出优秀那 95 分永远只会走到第一个分支。正确做法是把范围窄的条件放前面或者用互斥的区间写法。这个坑我见过太多次新人写完自测 95 分发现输出“及格”一脸茫然。另一个边界问题是浮点数的相等判断。if (0.1 0.2 0.3)这个条件的结果是 false因为二进制浮点表示存在精度误差。涉及小数比较时标准做法是判断差值绝对值是否小于一个极小值比如Math.abs(a - b) 1e-9。这不是钻牛角尖涉及金额、测量值的场景里这是必须处理的问题。double a 0.1 0.2; double b 0.3; // 错误写法 System.out.println(a b); // false // 正确写法 System.out.println(Math.abs(a - b) 1e-9); // true2.2 switch 的适用场景与穿透陷阱switch 处理的是“一个变量对多个固定值”的分支比一长串 if-else 更清晰可读性也更好。它支持的变量类型包括 byte、short、char、int、枚举、String从 Java 7 开始。选择 switch 的判断标准很简单如果分支条件是“等于某个具体值”用 switch如果是“大于小于某个范围”用 if。switch 最经典的坑就是忘写 break 导致的穿透。所谓穿透就是匹配到某个 case 后如果没有 break程序会继续执行下面所有 case 的代码直到遇到 break 或者 switch 结束。这个特性有时候能被利用比如多个 case 共享同一段逻辑switch (month) { case 1: case 3: case 5: case 7: case 8: case 10: case 12: days 31; break; case 4: case 6: case 9: case 11: days 30; break; case 2: days 28; break; default: throw new IllegalArgumentException(非法月份: month); }这种“多个 case 叠在一起”的写法是合理的但前提是你清楚地知道自己为什么这么写。更多时候穿透是 bug 的来源所以我给新人的建议是写完每个 case 立刻补上 break 或者 return把 default 也写上别图省事省略。default 的作用不只是兜底它还能帮你发现没考虑到的输入比如上面例子里直接抛异常比默默返回一个错误结果要好得多。2.3 循环三兄弟for、while、do-while 怎么选Java 有三种循环选哪种不是个人喜好问题而是由“循环次数是否确定”决定的。for 循环适合次数确定的场景因为它的初始化、条件、更新三部分写在一行里结构紧凑遍历数组、执行固定次数任务首选。while 适合次数不确定、只知道终止条件的场景比如读取输入直到用户输入退出、不断计算直到满足某个精度。do-while 和前两者的区别在于“至少执行一次”因为它是先执行循环体再判断条件。这个“至少执行一次”不是文字游戏。比如菜单程序你就需要先显示一次菜单再判断用户是否选择退出用 do-while 最自然。用 while 写的话你得在循环外面先把菜单显示一遍代码就啰嗦了。// 菜单场景do-while 更贴合语义 int choice; do { System.out.println(1. 查询 2. 添加 0. 退出); choice scanner.nextInt(); // 处理选择... } while (choice ! 0);for 循环还有一个变种叫增强 forfor-each专门用于遍历数组和集合。写法是for (元素类型 变量名 : 数组或集合)。它比普通 for 更简洁也不容易越界但有一个明确的限制遍历过程中无法通过索引修改数组元素也无法知道当前是第几个元素。所以需要索引或者需要修改元素值时还是得用普通 for。2.4 break、continue、return 的差别在哪这三个关键字经常被混为一谈其实作用域完全不同。break 是跳出当前这一层循环continue 是跳过本次循环剩下的代码直接进入下一次return 是结束整个方法的执行。理解它们的关键是搞清楚“跳出的是哪一层”。嵌套循环里 break 只跳出最内层这一点很容易出错。比如你在两层循环里找到了目标元素想立刻退出全部循环写一个 break 是不够的。常见解法有两种给外层循环加一个标签用break 标签名跳出指定层或者把查找逻辑抽成一个方法用 return 直接结束。后者更推荐可读性更好也符合“方法只做一件事”的原则。// 标签写法 outer: for (int i 0; i matrix.length; i) { for (int j 0; j matrix[i].length; j) { if (matrix[i][j] target) { System.out.println(位置: i , j); break outer; } } } // 方法写法推荐 private static int[] find(int[][] matrix, int target) { for (int i 0; i matrix.length; i) { for (int j 0; j matrix[i].length; j) { if (matrix[i][j] target) return new int[]{i, j}; } } return null; }continue 的使用要注意别把更新语句跳过了。在普通 for 循环里因为更新语句写在括号第三部分continue 不会跳过它但在 while 循环里如果计数器的自增写在循环体末尾continue 就会让它被跳过直接变成死循环。这类 bug 排查起来很费时间因为代码看起来完全正常跑起来就是卡住不动。3. 数组批量数据的第一个容器3.1 数组的声明、初始化与内存布局数组解决的问题是“一次存多个同类型数据”。它的声明有两种写法int[] arr和int arr[]前者是 Java 的推荐写法因为类型和变量名分得更清楚。初始化分静态和动态两种。静态初始化直接给值长度由值的个数决定int[] arr {1, 2, 3};。动态初始化指定长度元素取默认值int[] arr new int[5];此时五个元素全是 0。这里必须讲清楚一个概念数组一旦创建长度就固定了无法改变。这不是设计缺陷而是和它在内存中的存储方式有关。数组在堆内存中是一块连续的空间JVM 需要知道这块空间多大才能分配所以长度必须在创建时确定。所有“动态扩容”的数组工具类底层做的都是“新建一个更大的数组把旧数据复制过去”理解这一点后面学集合的扩容机制就顺理成章了。内存占用可以简单估算一下。一个int[100]占用 400 字节的数据区再加上对象头不同 JVM 实现有差异常见是 16 字节左右总共 400 多字节。这个估算在实际调优时会用到比如你预估要存百万级数据就得考虑内存够不够而不是拍脑袋写个new int[10000000]。3.2 遍历的几种写法与越界问题数组的遍历方式主要有三种普通 for、增强 for、以及 Java 8 之后用流处理。普通 for 通过索引访问最灵活能拿到下标也能修改元素。增强 for 写法简洁适合只读遍历。流处理适合对数组做过滤、映射、聚合等操作但它属于进阶内容基础阶段先把前两种用熟。越界是数组最常见的问题ArrayIndexOutOfBoundsException这个异常几乎每个 Java 开发者都遇到过。合法的索引范围是 0 到 length - 1访问 length 或者负数都会抛异常。防止越界最有效的手段是用arr.length而不是硬编码的数字在循环条件里写i arr.length而不是i arr.length。后者的“小于等于”是典型的手误因为人的直觉里“到最后一个”就是小于等于长度但数组索引是从 0 开始的。遍历方式能否获取索引能否修改元素适用场景普通 for能能需要下标或修改元素增强 for不能不能仅读到副本只读遍历代码简洁流处理需额外处理不推荐过滤、统计、转换提示增强 for 遍历基本类型数组时循环变量拿到的是元素的副本修改它不会影响原数组。这是很多人第一次用增强 for 时踩的坑以为自己写了赋值但数组没变。3.3 二维数组的本质与常见误用二维数组本质上是“数组的数组”也就是每个元素本身又是一个一维数组。理解这一点很多看似奇怪的现象就解释得通了。比如声明int[][] arr new int[3][4]表示有 3 个一维数组每个长度为 4。arr.length得到的是行数 3arr[0].length得到的是第一行的列数 4。二维数组允许每行长度不同这叫不规则数组。写法是先指定行数再逐行分配int[][] jagged new int[3][]; jagged[0] new int[]{1}; jagged[1] new int[]{2, 3}; jagged[2] new int[]{4, 5, 6};这种结构适合存“每行数据个数不固定”的场景比如每个学生选的课程数不同。如果直接用new int[3][4]强行搞成规则形状就会浪费空间遍历时也容易因为空值判断出问题。二维数组遍历必须用两层循环而且内层循环的条件要用arr[i].length而不是固定的列数否则遇到不规则数组就崩了。这是我在带新人时反复强调的一点写法上只差几个字符但决定了代码能不能兼容不规则数据。4. 方法把逻辑切成可复用的块4.1 方法定义的四要素与命名习惯一个方法由四部分组成修饰符、返回值类型、方法名、参数列表。修饰符基础阶段先记住public static两个就够用前者表示可以被外部调用后者表示不需要创建对象就能调用。返回值类型写void表示不返回任何值否则要写具体类型并且方法体内必须有对应的 return 语句。方法名的命名要遵循“动词开头、驼峰命名”的约定比如calculateAverage、isValid、findMax。这个约定不是形式主义好的方法名本身就是注释读代码的人不看方法体也能猜出它在干什么。反过来method1、doIt、process这种名字就是给未来的自己挖坑三个月后你自己也看不懂。参数列表里每个参数都要写类型多个参数用逗号分隔。参数个数不建议太多超过四个就该考虑是不是该封装成一个对象了或者这个方法本身承担了太多职责。返回值的类型要和实际返回的数据匹配基本类型和包装类型之间的自动装箱是隐式发生的但要注意 null 的问题。4.2 参数传递值传递的真相与常见误解这是 Java 基础里争议最多的一个话题。结论先放在这里Java 只有值传递没有引用传递。但很多人会反驳说“我传一个对象进去在方法里改了它的属性外面也变了这不是引用传递吗”。这个现象确实存在但它的机制是传递的是对象引用的副本副本和原引用指向同一个对象所以通过副本修改对象内容是可见的。关键在于“副本”两个字。如果方法内部让参数重新指向一个新对象外面的引用不受影响public class PassDemo { static void changeName(Person p) { p.name 新名字; // 影响外部改的是同一个对象的属性 p new Person(另一个); // 不影响外部只是让副本指向新对象 } static void changeArray(int[] arr) { arr[0] 99; // 影响外部修改数组元素 arr new int[]{1, 2}; // 不影响外部副本重新指向新数组 } }理解这个机制的实际价值在于它能帮你解释很多“为什么调完方法后数据没变”的问题。比如你在方法里对一个 String 参数做拼接或者对一个 Integer 参数做自增外面的值都不会变因为 String 和包装类都是不可变对象任何“修改”操作实际都是创建新对象并让副本指向它。参数类型方法内修改内容方法内重新赋值基本类型不影响外部不影响外部数组影响外部不影响外部普通对象影响外部改属性不影响外部String / 包装类不影响外部不影响外部4.3 方法重载与递归的边界意识重载是指同一个类里有多个同名方法但参数列表不同。注意“参数列表不同”包括参数个数、类型、顺序但不包括返回值类型。也就是说两个方法只是返回值不同这不叫重载编译会直接报错。重载的价值在于给调用者提供统一的语义比如println能打印各种类型就是靠重载实现的。递归是方法自己调用自己用得好能把复杂问题写得非常简洁比如树的遍历、阶乘计算。但用递归必须满足两个条件有明确的终止条件且每次递归都在向终止条件靠近。缺了前者会栈溢出缺了后者会无限递归。递归调用会占用栈空间每次调用压入一个栈帧默认栈大小下大概能撑几千到一万多层。计算阶乘这种线性递归n 一大就会StackOverflowError。所以涉及大数量的累加、累乘老老实实用循环更稳妥。递归更适合处理“问题规模天然可以分解成同类子问题”的场景比如目录树的遍历、汉诺塔这类用循环写反而绕。5. 把语法串起来一个完整的小实战5.1 需求拆解与数据结构选择光看语法点容易散我用一个具体的需求把这一篇的内容串起来读入一组学生成绩输出平均分、最高分、最低分并统计各等级优、良、中、及格、不及格的人数。这个需求足够小但把流程控制、数组、方法三块全覆盖了。拆解一下。数据存储成绩数量不确定但程序运行前可以问用户要输入几个然后创建对应长度的数组。核心逻辑分三块第一块是输入并存入数组第二块是遍历计算统计值第三块是输出结果。每一块都适合抽成一个方法主方法只负责串流程。这种“数据 处理逻辑分离”的思路是后面学习面向对象的前置思维值得早点建立。参数计算上有一点要注意平均分的计算中总分用 int 累加没问题但除法要用浮点否则 3 个科目总分 250 会得到 83 而不是 83.33。做法是把其中一个操作数强转为 double或者直接声明 double 类型的变量来接收。5.2 代码实现与逐段说明import java.util.Scanner; public class ScoreStat { public static void main(String[] args) { Scanner scanner new Scanner(System.in); System.out.print(请输入学生人数: ); int count scanner.nextInt(); if (count 0) { System.out.println(人数必须大于 0); return; } double[] scores inputScores(scanner, count); double average calcAverage(scores); double max findMax(scores); double min findMin(scores); System.out.printf(平均分: %.2f 最高分: %.1f 最低分: %.1f%n, average, max, min); countByLevel(scores); scanner.close(); } // 输入成绩做范围校验 private static double[] inputScores(Scanner scanner, int count) { double[] scores new double[count]; for (int i 0; i count; i) { System.out.print(第 (i 1) 个成绩(0-100): ); double s scanner.nextDouble(); if (s 0 || s 100) { System.out.println(成绩非法请在 0-100 之间); i--; // 抵消本次循环重新输入 continue; } scores[i] s; } return scores; } private static double calcAverage(double[] scores) { double sum 0; for (double s : scores) sum s; return sum / scores.length; } private static double findMax(double[] scores) { double max scores[0]; for (int i 1; i scores.length; i) { if (scores[i] max) max scores[i]; } return max; } private static double findMin(double[] scores) { double min scores[0]; for (int i 1; i scores.length; i) { if (scores[i] min) min scores[i]; } return min; } private static void countByLevel(double[] scores) { int excellent 0, good 0, medium 0, pass 0, fail 0; for (double s : scores) { if (s 90) excellent; else if (s 80) good; else if (s 70) medium; else if (s 60) pass; else fail; } System.out.println(优秀: excellent 人); System.out.println(良好: good 人); System.out.println(中等: medium 人); System.out.println(及格: pass 人); System.out.println(不及格: fail 人); } }这段代码里有几个值得单独说的点。第一输入校验里用了i--配合continue这是“输入非法就重来”的常见写法本质是让循环计数器回退一步下一次循环重新处理同一个位置。第二找最大值和最小值时初始值用的是scores[0]而不是 0因为成绩虽然都是正数但换成有负数的场景初始值设 0 就会出错从第一个元素开始比较是更通用的写法。第三等级判断用的是 if-else 链并且从高到低排列保证了互斥且不漏。5.3 运行结果验证与边界测试代码写完不能跑通一次就算完得测边界。至少要测四种情况正常数据比如 5 个成绩混合、全部相同验证最高最低不冲突、只有一个元素验证循环初始值没问题、输入非法值验证校验逻辑。我实际跑的时候第一种情况输出平均分 82.00最高 98最低 65五个等级人数加起来等于总人数这些自查点一个都不能少。还有一个容易被忽略的验证点是“人数为 0”的处理。如果在 main 方法里不判断 count 就直接创建长度为 0 的数组那么calcAverage里除数是 0double 除法会得到 NaN输出就变成一个奇怪的NaN。所以我在入口就把它挡住了。这类入口校验在真实项目里是标配宁可多写两行 if也别让脏数据流进核心逻辑。6. 常见问题与排查技巧实录6.1 编译期报错速查表新手阶段遇到的报错有相当一部分集中在几个固定类别整理出来对照着看能省不少时间。报错信息关键词常见原因处理方式cannot find symbol变量名拼错、方法不存在、没导包检查拼写确认 importincompatible types类型不匹配如 int 赋给 String做类型转换或改声明missing return statement有返回值的方法某条路径没 return补全所有分支的返回unreachable statementreturn 之后还写了代码删掉或调整位置variable might not have been initialized局部变量声明后未赋值就使用赋初始值其中unreachable statement在写 if-else 时特别常见比如 if 分支里 return 了else 分支里也 return 了然后在结构外面又写了一句逻辑编译器就会报这个。它不是语法错误而是逻辑上永远执行不到编译器帮你指出来其实是好事。6.2 运行期逻辑错误的定位方法编译过了但结果不对这类问题比语法错误难查因为它不报错。我的排查顺序是这样的先确认输入数据在关键位置打印出来看再缩小范围把可疑的方法单独拎出来测试最后看边界用 0、1、最大值、空数组这些极端输入去试。举两个真实遇到过的例子。一个是数组求和结果总是少一个数打印循环变量后发现循环条件写成了i arr.length - 1末尾元素被漏掉了。另一个是等级统计人数总和超过总人数原因是 if 链里用了独立的 if 而不是 else if一个 95 分的成绩同时满足了多个条件被重复计数。这两类问题都属于“代码看着没毛病跑起来结果微妙地不对”靠肉眼看很难发现必须打印中间值。提示调试时善用System.out.println打印中间变量虽然原始但在理解程序执行流程这件事上它比断点更直观尤其适合还在建立“代码怎么一步步执行”这个直觉的阶段。6.3 几个我自己踩过的坑第一个坑是循环里创建对象。早期写代码喜欢在 for 循环里new数组或者对象循环次数一多内存就吃紧还容易因为对象引用关系搞混。后来养成习惯能复用的对象放循环外实在要在循环里创建的先想清楚它的生命周期是不是只在这一轮。第二个坑是数组复制用了错误的写法。int[] b a;这不是复制这是让 b 指向同一个数组改 b 就是改 a。真要复制得用Arrays.copyOf或者System.arraycopy。这个错误在需要保留原始数据的场景里后果很严重比如排序前想存一份原数组做对比结果排序后两份都变了。第三个坑是递归忘了收敛。写过一个求和的递归终止条件写成了n 1但递归调用传的是n - 2n 是偶数时就永远到不了 1直接栈溢出。后来我的习惯是递归写完先手算 n 为 1、2、3 的情况能过再交给机器跑。这些坑的共同点都是“语法没错逻辑有缝”而这一篇里反复强调的动手验证和边界测试就是专门用来堵这些缝的。
阅读完成 · 觉得有帮助?
咨询建站