1. 先把“基础语法(二)”的边界划清楚
很多人学 Java 的时候会有个错觉:变量、运算符、数据类型学完,后面分支循环数组方法这些“看一眼就懂”,于是直接跳到集合和面向对象去了。等到写业务代码的时候,switch忘了写break导致穿透,for循环里改了循环变量结果跑飞,数组拷贝用了=结果两个变量指向同一块内存,方法传参以为能改掉外面那个变量结果没变——这类问题几乎每个新手都要踩一遍。这篇 Java 基础语法(二)就是专门讲这些“看着简单、用起来有坑”的东西:分支结构、循环结构、数组、方法定义与调用,以及围绕它们的一堆边界情况。
我写这篇的目的不是复述教科书目录,而是把我在带新人和做代码审查时反复见到的真实误区摊开讲清楚。每个语法点我会说明三件事:它在 JVM 里到底怎么执行、为什么这么设计、实际写代码时哪些地方最容易翻车。读者对象很明确——已经能写出Hello World和简单变量声明,正在往“能独立写出逻辑正确的代码”这个阶段走的人;当然,如果你已经工作两三年,也可以拿这篇当一份自查清单,看看自己有没有在某个细节上一直用错而不自知。
之所以值得单独花篇幅讲这一批语法,是因为它们构成了代码的“控制骨架”。一个类里写得最多的不是类声明,而是if、for、方法调用;线上事故里占比最高的也不是什么高深的并发问题,而是边界判断少了一个等号、循环少跑一次、数组下标多了一。骨架歪了,后面学再多框架也是歪的。
2. 分支结构:选哪种写法和怎么判断边界
2.1 if-else 的边界写法与嵌套控制
if的语法三分钟就能背下来,但真正决定代码质量的是边界条件怎么写。最常见的两种错误:一是把>=写成>,二是把“区间判断”写成了顺序颠倒的条件链。举个例子,判断分数等级,很多人会这么写:
int score = 85; if (score >= 60) { System.out.println("及格"); } else if (score >= 80) { System.out.println("良好"); } else if (score >= 90) { System.out.println("优秀"); }这段代码逻辑上是死的:只要分数大于等于 60,第一个分支就命中,后面两个分支永远不会执行,85 分会被打印成“及格”。正确的写法是从严格到宽松排列,先判断>= 90,再判断>= 80,最后判断>= 60。这个坑我见过不止一次出现在生产代码里,而且因为逻辑不报错、只是结果错,测试覆盖不到就一直潜伏着。所以写条件链有个简单原则:条件互斥优先,不能互斥就按“最严格优先”排序。
另一个高频问题是对null的处理。Java 里if (str.equals("abc"))和if ("abc".equals(str))在str为null时结果完全不同,前者抛NullPointerException,后者安全返回false。这个技巧叫“常量前置”,是老一辈程序员传下来的习惯,现在有了Objects.equals(a, b)可以更优雅地解决,但常量前置依然值得保留在肌肉记忆里——它不依赖任何工具类,一眼就能看出谁可能为 null。
嵌套层级也是需要克制的。三层以上的if嵌套读起来非常痛苦,解决办法是“提前返回”或者“卫语句”:
public String checkUser(User user) { if (user == null) { return "用户不存在"; } if (user.getStatus() != 1) { return "用户状态异常"; } if (!user.isVerified()) { return "用户未认证"; } return "校验通过"; }对比把这三段塞进嵌套if里的写法,扁平化的版本可读性提升是数量级的。我的经验是,只要一个方法的正常流程被缩进到了第三层,就应该考虑抽方法或者改写结构了,这跟具体业务无关,纯粹是给人眼看的。
注意:
if后面跟单条语句时省略大括号是合法的,但强烈不建议。我处理过一次线上问题,起因就是有人在省略大括号的if后面加了一行日志,结果那行日志变成了无条件执行,排查了两个小时。
2.2 switch 的穿透机制与表达式写法
switch的核心机制是“跳转表 + 顺序匹配”,理解这一点才能理解它为什么快、以及为什么会穿透。传统switch支持的类型是有限的:byte、short、char、int及其包装类,加上枚举(Java 5)和String(Java 7)。long、float、double都不支持,这一点新手经常忘。
String的switch底层并不是真的拿字符串做跳转表,编译器会把它翻译成“先算hashCode定位、再用equals二次确认”的两段式逻辑。这个细节的实际意义在于:如果你在case里写的是null值,会在hashCode()调用时直接抛 NPE。所以对可能为null的字符串做分支,必须先判空。
穿透(fall-through)是switch最容易被误解的特性。它本来是 C 语言留下的设计,允许“多个 case 共享一段逻辑”:
switch (day) { case 1: case 2: case 3: case 4: case 5: System.out.println("工作日"); break; case 6: case 7: System.out.println("休息日"); break; default: System.out.println("输入有误"); }这种用法是合理的、也是推荐保留的。但如果是漏写break导致的意外穿透,那就是 bug。判断标准很简单:如果你是有意让两个 case 共用逻辑,就正常写;如果是忘了 break,那就是事故。后者在代码审查里几乎必被指出,我在团队里定的规矩是“每个 case 结束时必须有 break 或注释说明为何穿透”,成本极低但效果很好。
Java 14 之后switch表达式正式转正,箭头写法彻底消除了穿透问题,还支持返回值:
String type = switch (day) { case 1, 2, 3, 4, 5 -> "工作日"; case 6, 7 -> "休息日"; default -> { System.out.println("异常输入"); yield "未知"; } };这里yield是块内返回值的写法,注意它不是return,return会直接结束整个方法。如果你的项目 JDK 版本在 14 以上,新代码优先用表达式写法;如果是 8 或者 11,那就老老实实用传统写法并保证每个分支都有break。另外提一句,default分支虽然语法上可选,但涉及枚举和外部输入的场景一定要写,因为别人后续往枚举里加值的时候,没有default的switch会静默什么都不做,这种“安静的 bug”最难查。
3. 循环结构:三种循环不是随便挑的
3.1 for、while、do-while 的适用边界
三种循环在功能上可以互相替代,但适用场景差别很大。for适合“次数已知”或者“有明确迭代器”的场景,因为初始化、条件、更新三要素写在头部,一眼能看完整个生命周期;while适合“次数未知、依赖运行时条件”的场景,比如读取输入流直到结束;do-while适合“至少执行一次”的场景,最典型的是命令行菜单循环——先展示菜单,用户操作完再判断是否继续。
do-while是新手里存在感最低的一个,但它在“重试”类场景里非常好用:先发起一次请求,失败了再看要不要重试。用while写就得把请求代码复制一份到循环外面,用do-while就干净得多:
int retry = 0; boolean success; do { success = doRequest(); retry++; if (!success && retry < 3) { System.out.println("第 " + retry + " 次失败,准备重试"); } } while (!success && retry < 3);注意这里循环条件必须同时判断success和重试次数,只写!success就是死循环。这也是do-while要注意的点:条件写在尾部,容易被忽略,读代码的人视线是往下的,但逻辑判断在最后,所以条件里包含的变量最好在循环体里有明显变化。
3.2 循环变量的修改禁忌与标签跳转
在for循环体里手动修改循环变量是个危险动作。下面这段代码看着没问题,实际输出只有偶数:
for (int i = 0; i < 10; i++) { if (i % 2 == 0) { i++; // 想跳过下一个数?结果把自己坑了 } System.out.println(i); }原因是i++在循环体执行后还会再执行一次,两次自增叠加,跳过的数比预想的多。这类问题不会报错,只会让结果对不上,排查起来非常折磨。我的建议是:循环变量只在for头部的第三段里修改,循环体里想跳过元素就用continue,想提前结束就用break。
说到break,嵌套循环里想跳出外层循环,Java 提供了带标签的写法:
outer: for (int i = 0; i < 5; i++) { for (int j = 0; j < 5; j++) { if (i * j == 6) { System.out.println("找到: i=" + i + ", j=" + j); break outer; } } }标签就是一个合法的标识符加冒号,break outer会直接跳出被标记的那层循环。这个语法被不少人认为“不优雅”,但在二维数组查找、矩阵扫描这类场景里,它比设置一个 flag 变量再层层判断要清晰得多。同理还有continue outer,用来跳过外层循环的当前轮次。用不用看团队习惯,但至少要知道它存在,别看到别人的代码里有标签就以为是语法错误。
3.3 死循环排查与循环性能的几个直觉
死循环是循环结构里最经典的故障。常见成因有四种:循环变量压根没变、条件永远为真、浮点数精度导致条件永不满足、以及while(true)忘写出口。前两种好查,第三种最隐蔽:
for (double d = 0.0; d != 1.0; d += 0.1) { // 永远不会结束 }原因是0.1在二进制里无法精确表示,累加十次得到的是0.9999999999999999而不是1.0,!=判断永远成立。这类写法必须改成用范围比较,比如d < 1.0,或者干脆用整数循环再除以精度因子。
关于循环性能,有些说法被传得很广但需要澄清。比如“for循环里把list.size()提出来能提速”,在早期 JDK 里确实有影响,现在 JIT 早就优化掉了,代码可读性优先,不必为此折腾。真正值得注意的反而是循环内部的重复计算和方法调用,尤其是那些带 IO 或者数据库访问的操作,把它们提到循环外能省下数量级的时间。另一个实际经验是:大循环里避免频繁字符串拼接,String是不可变对象,每次+都会产生新对象,几千次以上就该换StringBuilder了。
4. 数组:最朴素也最容易出错的数据结构
4.1 声明初始化的三种写法和默认值规则
Java 数组的声明有三种写法:int[] arr、int arr[]、int[] arr = new int[5]。第一种是推荐写法,因为类型信息集中在前面,int[]读作“整型数组”更符合直觉;第二种是 C 语言遗留风格,能编译但不推荐;第三种是真正分配内存的写法。这里要区分“声明”和“初始化”:int[] arr;只是在栈上放了一个引用,此时访问arr.length会直接编译报错(局部变量未初始化),而arr[0]更是无从谈起。
数组创建后元素会有默认值,这个规则必须记牢:int、short、byte、long为0,float、double为0.0,char为\u0000(空字符,不是空格),boolean为false,所有引用类型为null。这跟局部变量必须显式赋值才能使用的规则形成对比——数组元素有默认值,局部变量没有,很多新手在这里混淆,写int[] a = new int[3]; if (a[0] == 0)觉得理所当然,却不知道这个0是 JVM 帮忙填的。
数组的内存模型也值得说清楚:数组对象本身在堆上,包含一块连续的内存区域和记录长度的头部;栈上的变量只是一个指向堆的引用。所以int[] b = a;这个赋值操作复制的是引用,不是内容。修改b[0]会同步反映到a[0],这一点和基本类型变量赋值完全不同,也是数组相关 bug 的第一大来源。
4.2 越界、拷贝与工具类的正确用法
ArrayIndexOutOfBoundsException是新手最常见的运行时异常,合法下标范围是0到length - 1。写循环的时候用<=而不是<是经典错误:
int[] arr = {1, 2, 3}; for (int i = 0; i <= arr.length; i++) { // 错,会越界 System.out.println(arr[i]); }这个错误的隐蔽之处在于,如果数组元素刚好访问完没报错,你可能以为代码是对的,换个长度就炸了。我的习惯是写循环时先确认一遍“长度和下标的关系”,必要的话用增强for:
for (int value : arr) { System.out.println(value); }增强for没有下标,自然不存在越界问题,也不需要关心长度。它的局限是不能在遍历中修改元素(修改的是副本)以及拿不到索引,需要索引时还是得用传统for。
数组拷贝的三种方式区别很大:
| 方式 | 是否新对象 | 元素是否复制 | 适用场景 |
|---|---|---|---|
b = a | 否 | 否,共享同一数组 | 只是想要个别名,慎用 |
System.arraycopy | 是(目标需预先创建) | 浅拷贝 | 性能要求高、需要指定区间 |
Arrays.copyOf | 是 | 浅拷贝 | 常用,可扩容截断 |
clone() | 是 | 浅拷贝 | 简单场景 |
int[] src = {1, 2, 3, 4, 5}; int[] dst = new int[5]; System.arraycopy(src, 1, dst, 0, 3); // 从 src[1] 开始拷 3 个到 dst[0] System.out.println(Arrays.toString(dst)); // [2, 3, 4, 0, 0] int[] copy = Arrays.copyOf(src, 3); // 只拷前 3 个,长度变 3 System.out.println(Arrays.toString(copy)); // [1, 2, 3]System.arraycopy的参数有五个,顺序是“源数组、源起始下标、目标数组、目标起始下标、拷贝长度”,记不住的时候记住“源在前、目标在后、最后是长度”。它是 native 方法,性能很好,但要求做边界检查,越界会抛IndexOutOfBoundsException而不是静默截断。
还有一个必须强调的点:这些拷贝都是浅拷贝。对于int[]这种基本类型数组没区别,但对于String[]或者对象数组,复制的是引用,两个数组里的元素指向同一批对象。想深拷贝得自己遍历或者用序列化,这是另一个话题了。
4.3 二维数组与不规则数组
Java 的二维数组本质是“数组的数组”,不是矩阵。声明int[][] matrix = new int[3][4]会创建 3 个长度 4 的一维数组,内存上这些一维数组不保证连续。这个模型带来的直接好处是可以创建不规则数组:
int[][] triangle = new int[4][]; for (int i = 0; i < triangle.length; i++) { triangle[i] = new int[i + 1]; for (int j = 0; j < triangle[i].length; j++) { triangle[i][j] = i + j; } }注意new int[4][]只创建了外层数组,内层还是null,直接访问triangle[0][0]会 NPE。遍历二维数组时外层的length是行数,每行的length可能不同,所以内层循环条件必须写triangle[i].length而不是triangle[0].length。
Arrays.deepToString()是打印二维数组的正确方式,Arrays.toString()只会打印出一堆类似[I@1b6d3586的地址字符串。这个细节在调试时非常影响效率,我见过有人盯着地址串看了半天以为数据没填进去。
注意:数组的长度是
length属性,没有括号;字符串是length()方法;集合是size()方法。这三者混用是新手期最常见的编译错误之一。
5. 方法:从“能跑起来”到“可维护”
5.1 方法签名与值传递的真实含义
方法是 Java 里组织逻辑的基本单元,签名由“方法名 + 参数类型列表”决定,与返回值类型无关。所以int foo(int a)和String foo(int a)不能共存,编译器会报“方法重复定义”。这个规则解释了为什么重载不能靠返回值区分。
参数传递是 Java 里被误解最深的机制之一。准确的说法是:Java 只有值传递。对于基本类型,传的是值的副本,方法内修改不影响外部;对于引用类型,传的是引用的副本,方法内可以通过这个引用修改对象内部状态,但让引用指向新对象不会影响外部的引用。用一个图景来理解:引用是一把钥匙,传参是把钥匙复制一把给对方,对方能用这把钥匙开门改屋里的东西,但如果对方把手里这把钥匙换成另一把,你手里那把并不会变。
public static void modify(int[] arr) { arr[0] = 99; // 修改对象内容,外部可见 arr = new int[]{7, 8}; // 重新赋值引用,外部不可见 } public static void main(String[] args) { int[] data = {1, 2, 3}; modify(data); System.out.println(data[0]); // 99,不是 7 }理解了这个,很多“为什么我方法里改了半天外面没变”的困惑就消失了。反过来,如果你确实需要方法返回新的数组,就老老实实通过返回值传出去,别指望修改参数。
5.2 重载匹配规则与可变参数的坑
重载解析有一套优先级:先找精确匹配,再找自动类型提升(int→long→float→double),然后是装箱拆箱,最后才是可变参数。这套规则决定了下面这段代码的输出:
public static void print(int x) { System.out.println("int"); } public static void print(long x) { System.out.println("long"); } public static void print(Integer x) { System.out.println("Integer"); } public static void main(String[] args) { print(1); // int,精确匹配 print(1L); // long print(Integer.valueOf(1)); // Integer }变化一下:如果只有print(long)和print(Integer)两个重载,传入int会选谁?答案是long,因为“自动类型提升”的优先级高于“装箱”。这个规则不算常用,但遇到莫名的方法选择问题时,知道去哪儿查能省不少时间。
可变参数String... args本质是一个数组参数,它在编译期被转换成String[]。两个注意点:一是可变参数必须是参数列表的最后一项;二是它可以接收null或者直接传数组,这时候要小心args == null的判断,否则遍历时直接 NPE。还有一个容易忽略的地方——可变参数方法重载时,编译器可能产生歧义,比如同时定义foo(int...)和foo(Object...),调用foo()就会报“引用不明确”。这类问题不建议硬绕,直接改名最省事。
5.3 递归的边界与栈溢出预防
递归在基础语法阶段就要理解到位,因为后面学树、图、分治算法全靠它。递归三要素:终止条件、递归调用、状态收敛。写递归最常见的问题是终止条件写错或者根本忘了写,直接StackOverflowError。
public static int factorial(int n) { if (n < 0) { throw new IllegalArgumentException("n 不能为负数"); } if (n <= 1) { return 1; } return n * factorial(n - 1); }这里n <= 1而不是n == 1,因为n = 0时 0 的阶乘是 1,同时也顺手挡住了负数(虽然前面已经做了参数校验)。这种“多重防线”在递归里是值得的,因为一旦漏掉边界,故障表现是栈溢出,堆栈信息几十层,定位成本很高。
递归的深度受栈空间限制,默认线程栈大小在 512KB 到 1MB 之间,每层递归占用几十到上百字节,实际能撑住的深度通常在几千到一万层。这个量级做普通树遍历够用,但如果输入数据可能是十万级链表,递归就会爆栈,必须改写成迭代加显式栈。还有一个容易被忽视的点:递归写成尾递归也救不了 Java,Java 不支持尾调用优化,别指望编译器帮你把递归转成循环。
6. 这些坑我踩过:常见问题与排查速查表
把上面这些内容里最容易出问题的部分整理成表,遇到问题可以先在这里对一遍:
| 现象 | 大概率原因 | 排查动作 |
|---|---|---|
| 只打印了第一个分支 | 条件链顺序错误,宽松条件在前 | 按“最严格优先”重排 |
| 代码块原本该跳过的逻辑被执行了 | switch漏写break | 检查每个 case 末尾 |
String switch报 NPE | 传入null,hashCode调用失败 | switch 前判空 |
| 循环结果比预期少一轮 | 循环体里手动改了循环变量 | 只用continue或break |
| 循环永远不结束 | 浮点比较用!=或变量未更新 | 改范围比较、检查更新逻辑 |
| 方法里改了数组,外面没变 | 方法内new了新数组并赋值给参数 | 通过返回值传出 |
Arrays.toString打印出地址 | 对二维数组用了单层打印 | 换Arrays.deepToString |
| 遍历数组抛越界 | 循环条件写成<= length | 改为<或用增强 for |
| 两个数组互相影响 | 赋值只是引用复制,未拷贝内容 | 用Arrays.copyOf |
| 调用方法找不到 | 参数类型不匹配重载规则 | 显式强转或按签名对齐 |
| 递归直接栈溢出 | 终止条件缺失或未收敛 | 打印每层参数确认收敛性 |
再补充几个表格里塞不进去的经验。第一个是关于char的:char c = 'a' + 1;是合法的,结果是'b',因为char参与运算时会提升为int,赋值时再窄化回去;但char c = 'a' + 1L;就编译不过,因为结果是long,窄化转换需要强转。这个规则在处理字符编解码时经常遇到。
第二个是关于整数除法的:5 / 2结果是2不是2.5,而且-5 / 2结果是-2,是向零取整而不是向下取整。涉及金额计算时,任何时候都不要用double或float,用BigDecimal并配合setScale指定精度,这是金融类需求的硬规矩。
第三个是关于取中间值的溢出问题。二分查找里写int mid = (low + high) / 2;,当low和high都接近Integer.MAX_VALUE时会整数溢出变成负数。正确写法有两种:int mid = low + (high - low) / 2;或者int mid = (low + high) >>> 1;,后者利用无符号右移把溢出位当成正常位处理。这个 bug 在 JDK 的Arrays.binarySearch里真实存在过很多年,知道的人不多但确实值得记住。
7. 写完这些语法之后,我给新人的几条建议
语法层面的内容到这儿基本闭环了。分支、循环、数组、方法,这四样东西组合起来已经能写出完整的算法逻辑,也能应付绝大多数课后练习和面试手撕题。在这个阶段,我的建议是不急着上框架,先把这几类问题的“肌肉记忆”练出来。
第一是多写不带自动补全的代码。IDE 很聪明,会自动补全break、会提示数组长度、会在你写了错误重载时报黄,但它也会掩盖你对语法的真实掌握程度。我带的几个新人里,凡是能在记事本里手写完整循环和方法的,后面出语法 bug 的概率明显低很多。
第二是养成“写下标先想边界”的习惯。for循环条件、数组访问、字符串截取,凡是涉及下标的地方,先在心里过一遍“最小是多少、最大是多少、空的情况怎么办”。这个习惯养成之后,越界类异常基本可以杜绝。
第三是别怕写重复的代码来验证理解。比如不确定参数传递的行为,就写个二十行的main方法跑一遍,看到输出比看十遍文章都管用。Java 的语法规则不算多,但每一条的边界情况都值得亲手验证一次。
第四是读源码的时候留意基础语法的用法。JDK 里Arrays.sort、Objects.equals、String.valueOf这些方法内部大量使用我们今天讨论的东西,读起来不费力,但对“什么叫规范写法”的感受会越来越清晰。这种感受没法速成,只能靠积累。
最后说一句关于“基础”的看法。我在实际项目里见过太多人,框架玩得飞起,但一个简单的循环逻辑写出来要多跑一轮,或者数组边界判断错位导致数据错位一位。这类问题不会写在简历上,但会体现在每一次代码审查的意见里。基础语法扎实不扎实,平时看不出来,关键时刻全暴露。今天聊的这些东西,代码不超过几百行,但真正在业务里做到不出错,需要的是反复练和反复踩坑之后的警觉。
8. 数组与方法的组合实战:一个小工具类的完整实现
光讲语法点难免碎片化,我把前面那些零散的知识串到一个具体的小场景里:写一个数组工具类,实现“查找最大值、区间求和、数组反转、二分查找”四个功能。别看功能简单,每个都藏着前面提到的坑,正好当一次综合演练。
8.1 需求拆解与接口设计
先明确接口。我打算提供四个静态方法,都放在一个工具类里,外部直接通过类名调用,不需要实例化。设计上遵循几个原则:参数校验放在方法入口、返回值语义明确、不修改传入的原始数组(除非方法名明确表示会修改)。
public class ArrayTool { public static int max(int[] arr) { ... } public static int sumRange(int[] arr, int from, int to) { ... } public static void reverse(int[] arr) { ... } public static int binarySearch(int[] sortedArr, int target) { ... } }为什么用静态方法而不是普通方法?因为这类工具方法没有状态,全靠参数驱动,做成静态省去了实例化的开销,调用方也更直观。这也是 JDK 里Arrays、Collections这些工具类的通用做法。不过要注意,静态方法没法被多态覆盖,如果你的场景里需要子类替换行为,那就得改成实例方法或者接口默认方法。
参数校验这一块,我倾向于在方法入口直接抛IllegalArgumentException,而不是返回一个特殊值比如-1或者0表示失败。原因是特殊返回值容易和正常结果混淆,比如求和结果是0到底是数组为空还是数据本身就是 0,调用方根本分不清。抛异常虽然显得“重”,但语义明确,能强制调用方处理异常情况。当然,如果是对性能极其敏感的路径,也可以用assert或者干脆约定由调用方保证参数合法,这属于设计取舍,没有绝对对错。
8.2 四个方法的实现细节与选型理由
先看max。最直觉的写法是排序后取最后一个,但排序是O(n log n),而找最大值只需要遍历一遍,O(n)就够了。这种“不用排序也能解决”的判断,是写代码时该有的基本意识:
public static int max(int[] arr) { if (arr == null || arr.length == 0) { throw new IllegalArgumentException("数组不能为空"); } int max = arr[0]; for (int i = 1; i < arr.length; i++) { if (arr[i] > max) { max = arr[i]; } } return max; }初始值取arr[0]而不是0,这个细节很关键。如果数组全是负数,初始值设成0就会返回错误结果。这是我在代码审查里纠正过最多的一个点,尤其是从其他语言转过来的人容易顺手写int max = 0;。循环从i = 1开始,因为第 0 个元素已经作为初始值了,少一次比较。
再看sumRange,区间求和,坑在于“闭区间还是半开区间”以及边界校验。我选择闭区间[from, to],因为业务代码里调用方更习惯说“从第 3 个到第 5 个”,包含两端更符合直觉:
public static int sumRange(int[] arr, int from, int to) { if (arr == null) { throw new IllegalArgumentException("数组不能为空"); } if (from < 0 || to >= arr.length || from > to) { throw new IllegalArgumentException("区间非法: [" + from + ", " + to + "]"); } int sum = 0; for (int i = from; i <= to; i++) { sum += arr[i]; } return sum; }这里from > to的判断容易被省略,但如果不判断,循环一次都不执行返回0,调用方会误以为区间内元素之和真的是 0。抛出异常虽然有成本,但比静默返回错误结果要好得多。另外注意累加用int可能溢出,如果是金额或者长度较大的场景,应该改用long,这一点在接口设计时就要想清楚。
reverse是唯一一个会修改原数组的方法,所以命名上不加get前缀,明确告诉调用方“我有副作用”。实现用双指针从两端往中间交换,时间复杂度O(n),空间O(1):
public static void reverse(int[] arr) { if (arr == null || arr.length <= 1) { return; } int left = 0; int right = arr.length - 1; while (left < right) { int temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } }循环条件必须写left < right而不是left <= right。如果写等号,长度为奇数时中间那个元素会被自己和自己交换一次,结果虽然正确但多做了一次无用功;更麻烦的是某些场景下会出问题。这个边界我在早期的笔试里错过一次,后来就记住了。
binarySearch是四个方法里坑最多的,因为它要求输入有序,还涉及中间值计算。写成返回下标,找不到返回-1:
public static int binarySearch(int[] sortedArr, int target) { if (sortedArr == null) { throw new IllegalArgumentException("数组不能为空"); } int low = 0; int high = sortedArr.length - 1; while (low <= high) { int mid = low + (high - low) / 2; if (sortedArr[mid] == target) { return mid; } else if (sortedArr[mid] < target) { low = mid + 1; } else { high = mid - 1; } } return -1; }三个地方值得单独说。第一,high初始值是length - 1,配合while (low <= high)使用,这是闭区间写法;如果high初始值写length,循环条件就得改成low < high,两种写法不能混。第二,中间值用low + (high - low) / 2而不是(low + high) / 2,防止两个大数相加溢出,前面提到过。第三,low = mid + 1和high = mid - 1里的加减一不能少,少了就是死循环,因为mid可能一直等于low或者high。
8.3 边界测试清单与踩坑复盘
方法写完不等于完工,得有一份边界测试清单跑一遍。我习惯按“空、单元素、两元素、有重复、全相同、找不到”这几个维度设计用例,覆盖住了基本就能放心:
| 用例类型 | 输入 | 预期 |
|---|---|---|
| 空数组 | new int[0] | 抛IllegalArgumentException |
| 单元素 | [5] | max 返回 5,reverse 后仍是[5] |
| 全负数 | [-3, -7, -1] | max 返回 -1 |
| 含重复 | [2, 3, 3, 5] | 二分查找返回任意一个 3 的下标 |
| 首尾元素 | 查找第一个和最后一个 | 都能命中 |
| 不存在 | 查找比最小值还小的值 | 返回 -1 |
| 区间越界 | sumRange(arr, -1, 2) | 抛IllegalArgumentException |
这套用例看着基础,但我用它在实际项目里抓到过不少问题。有一次是sumRange在from == to时返回了 0,原因是我写成了for (int i = from; i < to; i++),少了个等号。还有一次是reverse传入空数组时抛了异常,因为我在空判断前就访问了arr.length,顺序放反了。这类问题单看代码不容易发现,跑一遍用例立刻现形。
我个人在实际操作中的体会是,这几个小方法写十遍不如带着“边界清单”写一遍。写的时候每敲完一个循环条件就停下来问一句“这个等号该不该有”,比事后调试半小时划算得多。工具类的价值不在于功能多复杂,而在于它在各种奇怪输入下都能给出明确、可预期的行为,这才是它能被放心复用的前提。