“这题看起来也太简单了吧?”——如果你在搜索引擎里搜过“成绩等级评定”“多分支”“方法”,大概率看到过很多代码截图,也大概率有过这种想法。但我在实际带新人、看代码评审的时候发现,恰恰是这种“简单题”最能暴露一个人写代码的基本功。一个分数转等级的需求,从最初的 if-else 堆叠,到方法封装,再到数据驱动、策略模式,背后藏着的是程序员的整个进阶路径。
这篇文章就是围绕“成绩等级评定(多分支 + 方法)”这个经典练习展开的。我用 Java 作为主要示例语言,穿插 Python、C 语言的写法对比,把多分支结构怎么用、方法怎么拆、边界值怎么处理、测试用例怎么设计、后续怎么演进,一条线完整讲清楚。不管你是刚学编程的学生,还是已经工作但想补基础的同学,都能在这篇文章里找到值得细品的内容。
1. 这道题背后到底在考什么
先说点实在的。很多初学者拿到“成绩等级评定”这道题,第一反应是:这不就是把分数跟 90、80、70、60 比一比吗?有什么好讲的?但如果你去翻各种教材、网课、面试题,会发现这道题几乎出现在每一本编程入门书里,而且反复出现。它不是没理由的。
1.1 一个看起来人畜无害的需求
先明确需求。最经典的版本是这样的:
输入一个百分制成绩(0~100),根据分数输出等级:90 分及以上为 A,80~89 为 B,70~79 为 C,60~69 为 D,60 分以下为 E。
有些版本还会加“优秀、良好、中等、及格、不及格”这种中文描述,本质是一回事。这个需求涉及的输入只有一个——分数;输出也只有一个——等级。数据流非常简单,没有任何复杂的业务逻辑。
但就是这么简单的需求,想写得“对、稳、好”,需要掌握的知识点可不少:
- 多分支结构:if-else if-else 还是 switch?
- 布尔表达式的书写:区间怎么界定,边界值算谁?
- 方法的定义和调用:逻辑放 main 里还是独立方法里?
- 参数校验:输入 120 分怎么办?输入 -3 分怎么办?
- 可读性与可维护性:别人看一眼能懂吗?改需求时好改吗?
这五个层次,恰好对应了一个编程学习者从“能跑就行”到“写得像样”的完整进化过程。
1.2 这类练习为什么值得写
我见过不少人嗤之以鼻:“工作里谁写这种代码啊?”这话对,也不对。真实业务确实不会有这么直白的“成绩转等级”,但它的变体到处都是:根据用户积分算会员等级、根据订单金额计算折扣档位、根据设备温度返回不同告警级别……这些实际需求的内核,跟成绩等级评定一模一样,都是“一个数值输入 + 多档区间映射 + 一个输出结果”。
把这道题吃透,等于把这类“区间映射”问题的通用解法掌握了。后面遇到任何类似需求,你都能条件反射地写出既稳定又容易改的代码。从这个角度说,我愿意给这道看似简单的小题一个比较高的评价——它是训练分支逻辑、方法设计、边界思维的一个绝佳模型。
1.3 本文的代码约定
为了不让讨论过度发散,后面所有代码默认用 Java 17 写(Java 8 也完全兼容,我基本不用 17 的新特性),个别地方我会对比 Python 的写法。所有的代码都遵循一个原则:代码量不追求最简,而是追求“逻辑清晰,便于教学”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多分支的两条路线:嵌套区间还是等值分发
2.1 if-else if 版本:最直观但最容易写飘
先看最经典的写法。百分之八九十的初学者第一次写出来的是这样的:
java复制public class GradeDemo {
public static void main(String[] args) {
int score = 85;
if (score >= 90) {
System.out.println("A");
} else if (score >= 80) {
System.out.println("B");
} else if (score >= 70) {
System.out.println("C");
} else if (score >= 60) {
System.out.println("D");
} else {
System.out.println("E");
}
}
}
这段代码能跑吗?能。逻辑对吗?对于 0~100 的合法输入,是对的。但我每评审一次这种代码,都会多问一句:如果输入 150 分呢?如果输入 -10 分呢?答案就尴尬了——它会输出 E 或 A,而不是提示“非法分数”。
这就是边界意识问题。很多初学者把注意力放在“分支怎么写”上,而忽略了“输入值本身是否合法”。真实世界里,你永远不能假设调用方会给你一个正常值。你写给用户用的程序,用户可能输错;你写给同事调用的方法,同事可能传错。所以,一个健壮的程序,第一步不是处理主线逻辑,而是拦住非法输入。
在 if-else if 的写法上,还有两个细节值得注意。
第一个是区间顺序。上面的代码从 90 开始往下判断,这个顺序不是随便定的。因为 if-else if 有一个关键特性——只要某个条件为真,后面所有分支都会被跳过。所以你必须把范围大的条件放在前面,把范围小的放在后面。反过来写,比如先判断 score >= 60,那 85 分就会被归到 D,逻辑全乱了。
第二个是边界值的归属。90 分算 A 还是 B?按上面的写法 score >= 90,90 是 A。如果需求说“含 90 分算 B”,那你的条件就得改成 score > 90。这种边界值的差异,正是测试时最容易遗漏的地方。我自己的习惯是:拿到需求先找边界,把 0、59、60、69、70、79、80、89、90、99、100 全部列成一张表,然后逐个验证。
2.2 区间写法:把条件写成数学区间
除了 if-else if 的链式写法,还有一种更“学院派”的写法——把每个等级的条件写成一个完整的区间:
java复制if (score >= 90 && score <= 100) {
grade = 'A';
} else if (score >= 80 && score < 90) {
grade = 'B';
} else if (score >= 70 && score < 80) {
grade = 'C';
} else if (score >= 60 && score < 70) {
grade = 'D';
} else if (score >= 0 && score < 60) {
grade = 'E';
} else {
// 非法输入
}
这种写法的优点是每个分支都自包含,读代码的人不需要关心前面的分支执行顺序。缺点是啰嗦,而且因为每个条件都写了上下界,稍不留神就会把边界写错。
从实际工程角度看,这两种写法的选择更多是审美问题。但我要提醒一点:当区间判断逻辑变复杂时(比如既有百分制等级又有五级制等级),链式写法会比区间写法更难维护。我见过太多因为条件顺序写错而导致的诡异 bug。
2.3 switch:适合等值分发,不适合区间判断
既然标题里有“多分支”,那很多初学者自然会想到 switch。我直接说结论:传统的 switch 不适合区间判断。原因很简单:switch 的分支条件是“等值匹配”(match 某个常量),而区间判断是“范围匹配”(match 某个范围)。你没法写 case score >= 90。非要用 switch 也不是不行,可以把分数除以 10 取整,然后再 switch:
java复制switch (score / 10) {
case 10:
case 9:
grade = 'A';
break;
case 8:
grade = 'B';
break;
case 7:
grade = 'C';
break;
case 6:
grade = 'D';
break;
default:
grade = 'E';
}
你看,score / 10 把 90~100 映射成 10 和 9,80~89 映射成 8,70~79 映射成 7,以此类推。这实际上是把区间判断“降维”成了等值判断。这个技巧在性能要求极端苛刻的场景(比如某些嵌入式系统)下有一定价值,因为 switch 的跳转表比 if-else 链更快。但在绝大多数业务系统里,这点性能差异完全可以忽略,而可读性却下降了。
所以我的建议是:业务代码里,区间判断用 if-else if;等值判断用 switch。 别为了秀技巧而把代码写得让人看不懂。顺便提一句,Java 14+ 的 switch 表达式(箭头语法)确实更优雅了,但那是后话,跟“多分支区间判断”的配合依然有限。
2.4 多分支的性能与可读性权衡
有同学会问:if-else if 最多有几个分支?如果我有一百个等级怎么办?这里就要谈到性能问题了。if-else if 是顺序判断的,最坏情况下要比较一百次。所以当分支数量非常多时,通常会用“查找表”(数组、Map、二分查找)来替代。比如用二分查找,一百个等级最多比较 7 次。
不过,对于“成绩等级评定”这种只有五六个分区的场景,谈性能就是耍流氓。真正要考虑的是:代码是不是一眼能看懂? 如果分支数量已经多到 if-else if 让人看不下去,说明这个需求本身就应该换个数据结构来表达了。下一章我会专门讲“方法封装”,再下一章会讲“数据驱动”,那才是更高级的解法。
3. 方法封装:从“在 main 里堆代码”到“让方法职责单一”
讲完分支,自然要讲方法。因为如果只写 main 方法里的代码,第 2 章的那些写法就是全部了;但一旦涉及方法,事情就变得有意思起来。
3.1 为什么要抽方法
我见过很多入门教程在主方法里写完逻辑就结束,很少解释“为什么要抽方法”。这导致很多人学完分支、循环、数组,写出来的代码全是“一个 main 从头写到尾”。等到代码量大了,main 方法几百上千行,改一个需求跟在一片雷区里走路一样。
抽方法的核心动机有三个:复用、隔离、可测。
先说复用。如果“分数转等级”的逻辑只在一个地方用,那写不写方法无所谓。但真实项目里,同一个逻辑常常被多处调用——比如你既要控制台输出等级,又要写进日志,还要返回给前端。不抽方法,就得复制粘贴三份同样的 if-else,改一个边界条件要同步改三处,漏一处就是 bug。抽成方法,改一处就处处生效。
再说隔离。把“分数转等级”的逻辑从 main 方法里挪出去,main 方法就只负责“输入、调用、输出”。以后 main 变复杂了(比如变成 Spring Boot 的 Controller),等级判断逻辑不需要跟着改。这就是“单一职责”的朴素版本。
最后是可测。方法独立的另一个好处是能单独写测试。比如你写一堆测试用例验证 getGrade 方法,编译运行一下就知道对不对,比每次手动启动整个程序快得多。真正的单元测试框架(JUnit、pytest、CUnit)都是建立在这个基础之上的。
3.2 方法签名设计:返回类型怎么选
好,现在说怎么设计这个方法的签名。最直接的版本:
java复制public static char getGrade(int score) {
if (score >= 90 && score <= 100) {
return 'A';
} else if (score >= 80) {
return 'B';
} else if (score >= 70) {
return 'C';
} else if (score >= 60) {
return 'D';
} else if (score >= 0) {
return 'E';
} else {
return '?';
}
}
注意两个设计决策。第一,为什么返回 char 而不是 String?因为等级是单个字符,用 char 更高效,也更语义化。当然,如果需求是返回“优秀、良好”这种中文描述,那就得返回 String。第二,非法输入返回什么?我暂时用 '?' 表示“不知道”,但这并不是一个好设计——下面马上会说。
实际开发里,等级有时候不只是单个字符,还可能包含“是否及格”“绩点”“颜色标识”等多维信息。这时候你会发现,返回一个 char 就不够用了。更好的做法是返回一个对象或者枚举。我在第 5 章会详细讲枚举的玩法,这里先埋个伏笔。
3.3 参数校验:把非法输入挡在方法入口外
这大概是整个“成绩等级评定(多分支 + 方法)”里最值得认真讲的一节。
我在 3.2 的代码里,最后的 return '?' 其实是一种极其粗糙的“兜底”。当分数小于 0 或大于 100 时,这个方法返回一个问号。调用方拿到问号,只能一脸懵:这是哪个等级?所以,正确的做法是——非法输入应该直接抛出异常,而不是返回一个特殊值。
java复制public static char getGrade(int score) {
if (score < 0 || score > 100) {
throw new IllegalArgumentException("分数必须在 0~100 之间,当前值为:" + score);
}
if (score >= 90) {
return 'A';
} else if (score >= 80) {
return 'B';
} else if (score >= 70) {
return 'C';
} else if (score >= 60) {
return 'D';
} else {
return 'E';
}
}
这段代码有几个优点:
第一,非法输入的检测放在方法最前面,一旦不合法就抛异常,后面的主体逻辑根本不会执行。这叫做 fail fast(快速失败),是一种非常好的工程实践。
第二,异常信息里包含了实际传入的分数 当前值为:85,调试的时候一眼就能看到问题所在。如果你的异常信息只有“分数非法”四个字,到时候排查问题还得猜。
第三,合法输入的判断条件只在最前面的一个 if 里,后面的 if-else if 不需要再写 score <= 100 这种条件了,因为我们已经保证它合法。这相当于把“区间判断”化简成了“下界判断”,代码反而更简洁。
这里要特别提醒:Java 方法里,分支结构必须有返回值或抛异常,否则编译不过。上面的写法和之前的写法都能通过编译,因为最末尾的 else 里返回了 'E'。如果你把非法校验写在前面,又把最后一个 else 去掉,编译器会报“缺少 return 语句”——这是初学者常见的一个编译错。
3.4 静态方法还是实例方法?
在 Java 这样的面向对象语言里,还有一个灵魂拷问:getGrade 到底应该定义成静态方法还是实例方法?
纯静态方法的好处是调用方便——GradeUtil.getGrade(85) 一行搞定,不需要 new 一个对象。简单工具类里的纯函数很适合静态方法。但静态方法不能参与多态,不能实现接口,很难做依赖注入,所以不适合处理有状态、可扩展的业务。
我的建议很简单:如果这是一个全局通用、无状态的工具逻辑,用静态方法没问题;如果是业务逻辑的一部分,未来可能根据等级做更多处理(比如“及格才能发证书”),那就应该设计成实例方法,甚至把“等级”建模成对象。
这也是我在教学里反复强调的一点:先写对,再把“应该写成什么形态”的问题留给设计模式。别一上来就套这模式那模式,那就成了过度设计。
4. 边界值、异常输入和测试用例:多分支代码的真正考验
4.1 边界值法是什么
你可能会觉得,代码不是已经写完了吗?第 3 章那个 getGrade 方法,我一看就懂,没什么问题。但问题恰恰隐藏在“你一看就懂”的地方——真正的 bug 往往出现在你“看一眼觉得没问题”的边界值上。
边界值法(Boundary Value Analysis)是软件测试里最经典的设计用例方法之一,简单说就是:取每个区间的边界值、靠近边界的值和等价类中的典型值,把它们都测一遍。对于成绩 0~100 分、五档等级这个需求,等价类和边界值是下面这样的:
| 输入区间 | 等级 | 边界值/典型值 |
|---|---|---|
| 90~100 | A | 89(边界下)、90(边界上)、99、100 |
| 80~89 | B | 79(边界下)、80、89 |
| 70~79 | C | 69、70、79 |
| 60~69 | D | 59、60、69 |
| 0~59 | E | 0、1、58、59 |
| 非法输入 | 抛异常 | -1、101、Integer.MIN_VALUE、Integer.MAX_VALUE |
为什么这组数据有价值?因为它能强制你去思考:89 分到底是不是 B?90 分到底是不是 A?多数人写 if 的时候顺手写的 >= 和 <,其实都没认真核对过边界。
如果拿这组数据跑一遍,你会发现一个有意思的现象:用第 3 章倒数第二个代码(也就是保留了 score >= 90 && score <= 100 那种完整条件的版本),100 分可以正确返回 A;但如果把条件写成 score > 90,100 分能过,90 分就会被错误地归到 B。这就是边界值最容易暴露出来的问题。
4.2 用 JUnit 跑一遍边界测试
光在脑子里想不算数,能跑起来才算数。下面给一个最简单的 JUnit 5 测试类,可以直接抄走用到自己的项目里:
java复制import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class GradeUtilTest {
@Test
void testBoundaryValues() {
assertEquals('A', GradeUtil.getGrade(100));
assertEquals('A', GradeUtil.getGrade(90));
assertEquals('B', GradeUtil.getGrade(89));
assertEquals('B', GradeUtil.getGrade(80));
assertEquals('C', GradeUtil.getGrade(79));
assertEquals('C', GradeUtil.getGrade(70));
assertEquals('D', GradeUtil.getGrade(69));
assertEquals('D', GradeUtil.getGrade(60));
assertEquals('E', GradeUtil.getGrade(59));
assertEquals('E', GradeUtil.getGrade(0));
}
@Test
void testInvalidInput() {
assertThrows(IllegalArgumentException.class, () -> GradeUtil.getGrade(-1));
assertThrows(IllegalArgumentException.class, () -> GradeUtil.getGrade(101));
assertThrows(IllegalArgumentException.class, () -> GradeUtil.getGrade(Integer.MIN_VALUE));
assertThrows(IllegalArgumentException.class, () -> GradeUtil.getGrade(Integer.MAX_VALUE));
}
}
跑一遍这个测试,你会得到两个结果:一是代码逻辑在边界上是正确的,二是你对“边界值法”有了直观感受。以后写任何区间判断代码,都能条件反射式地列一张类似的边界值表。
需要提醒的是,assertEquals 对 char 和 String 的行为不同。char 是基本类型,直接比较值;String 是比较内容。如果你把返回值从 char 改成 String,断言也要相应改。
4.3 处理用户输入的额外细节
如果这是一个控制台程序,需要在 main 方法里读取用户输入,那你还需要考虑:用户输入的是整数还是小数?输入的是“85分”这种带中文的字符串怎么办?输入的是一个空行怎么办?
初学者最常犯的错误是直接用 Scanner.nextInt(),一旦用户输入了非数字,程序直接崩溃。一个更稳妥的读法,是先用 nextLine() 读一行字符串,然后用 parseInt 解析,解析失败时给用户提示并允许重试。这才是真实项目里需要的健壮性。
5. 进阶演进:从分支语句到数据驱动、枚举和策略模式
到了这一步,你已经能把 getGrade 写得非常稳了:参数校验、边界值测试、异常处理都齐活。但真正想从“能用”走向“好维护”,还得继续往前推一步。
5.1 当规则变化时,if-else 会让你付出什么代价
假设现在是学期末,教务处突然说:今年改革,85 分就算 A,75 分算 B,65 分算 C,60 分算 D,其余不及格。
面对这种需求变更,if-else 版本怎么改?你得把每个分支的边界条件全部改一遍。如果这个判断逻辑只在一个方法里还好;万一分布在多个文件里,那就是一场灾难。改完还得重新做一遍边界值测试。如果是数据驱动呢?只需要改一张配置表。
5.2 用数组或集合构建“区间表”
“数据驱动”的思路是把“分数区间 -> 等级”这种映射关系,从代码里剥离出来,变成数据。最简单的做法是用两个数组:
java复制public class GradeRule {
private static final int[] SCORE_THRESHOLDS = {90, 80, 70, 60, 0};
private static final char[] GRADES = {'A', 'B', 'C', 'D', 'E'};
public static char getGrade(int score) {
if (score < 0 || score > 100) {
throw new IllegalArgumentException("分数必须在 0~100 之间:" + score);
}
for (int i = 0; i < SCORE_THRESHOLDS.length; i++) {
if (score >= SCORE_THRESHOLDS[i]) {
return GRADES[i];
}
}
throw new IllegalStateException("无法匹配等级");
}
}
这里的逻辑是:从 90 开始往下找第一个“分数 >= 阈值”的档位,找到了就返回对应的等级。区间和等级被压缩成了数组,以后改等级标准,只需要改数组内容,不需要动判断逻辑。
如果阈值比较多,你还可以对数组用二分查找,把时间复杂度从 O(n) 降到 O(log n)。不过对于五个档位,顺序查找已经足够快了。
5.3 用枚举表示等级并附加行为
前面提到过,返回 char 不够用。真实业务里,等级往往还携带别的信息——比如 A 级的绩点是 4.0,B 级是 3.0,C 级是 2.0,D 级是 1.0。这时候用枚举是最自然的建模方式:
java复制public enum Grade {
A(4.0, "优秀"),
B(3.0, "良好"),
C(2.0, "中等"),
D(1.0, "及格"),
E(0.0, "不及格");
private final double gpa;
private final String description;
Grade(double gpa, String description) {
this.gpa = gpa;
this.description = description;
}
public double getGpa() {
return gpa;
}
public String getDescription() {
return description;
}
}
然后 getGrade 方法的返回类型就变成 Grade。这样做的好处非常明显:调用方拿到 Grade.A,就能直接调用 getGpa()、getDescription(),甚至可以写 grade.isPass()——A、B、C、D 返回 true,E 返回 false。等级从“一个字符”变成了“一个对象”,能力和语义都更丰富了。
5.4 策略模式:当判断逻辑本身也要扩展
如果哪天需求不是“分数越高等级越高”,而是出现“体育成绩按性别分别计算”“不同课程用不同评分规则”,那光是数据驱动也不够了。这时候需要把“评分规则”本身抽象成策略。
定义一个接口:
java复制public interface GradePolicy {
Grade getGrade(int score);
}
然后为每种规则写一个实现类:
java复制public class DefaultGradePolicy implements GradePolicy {
@Override
public Grade getGrade(int score) {
if (score < 0 || score > 100) {
throw new IllegalArgumentException("分数必须在 0~100 之间:" + score);
}
if (score >= 90) return Grade.A;
if (score >= 80) return Grade.B;
if (score >= 70) return Grade.C;
if (score >= 60) return Grade.D;
return Grade.E;
}
}
public class GenderedGradePolicy implements GradePolicy {
// 性别差异化规则,略
}
使用时可以动态切换策略,比如男生用 A 策略、女生用 B 策略,或者根据课程类型选择不同的策略。这就是策略模式。对于“成绩等级评定”这个题目来说,这已经属于比较高级的演进了,但思路值得理解——核心思想是:把会变的东西抽象成接口,把稳定的东西留在实现里。
5.5 你该如何选择?
最后给个实用建议:不要一上来就堆设计模式。“成绩等级评定”这种需求,大多数场景下 if-else 就够了,数据驱动是性价比极高的改进,枚举是提升语义的好手段,策略模式留给真正需要它的时候。
我做项目时有个朴素的判断标准:如果同一个规则在未来三个月内没有明确的变更多次,就先用最简单的写法;等变化真的来了,再重构也不迟。 过度设计比设计不足更常见,也更容易让团队的项目变得难懂。把基础的 if-else 和边界测试写好,已经超过了大多数写代码的人。剩下的,是在需求逼着你前进一步时,你恰好知道该往哪走。
我在实际工作中最强烈的体会就是:像“成绩等级评定”这样的题目,看起来是给大一学生练手的,但它把分支、方法、边界、数据结构、设计思想这几层东西串成了一条完整的学习链。这条链不是通过一道题能完全掌握的,但通过这道题,你至少能摸到链条的每一个环节在哪。把每个环节理解透,以后遇到任何“区间映射”需求,你都不会再虚。
