
1. 从实验报告到能跑的小工具这个自动出题判分程序到底在练什么很多人看到面向对象程序设计和实验报告这两个词凑在一起第一反应就是又要在控制台里写几个类、继承一下、多态一下然后交差了事。我当年也是这么想的直到有一次帮人看一个自动出题判分的小程序才发现这个题目其实是个麻雀虽小五脏俱全的典型场景——它逼着你把封装、继承、多态、接口、集合、随机数、字符串处理、异常处理这些知识点全部串起来用一遍而不是孤立地背概念。这个自动出题判分小程序说白了就是让程序自己生成题目、接收用户作答、自动判断对错并给出分数。听起来简单但真正动手写就会发现题目怎么抽象、判分逻辑怎么设计、不同题型怎么扩展每一步都在考验你对面向对象思想的理解深度。它适合正在学C#面向对象这一章的学生也适合想找个练手项目巩固基础的初学者。我下面会把这个程序从设计思路到代码落地完整拆一遍重点讲清楚为什么这么设计以及实际写的时候哪里容易翻车。先明确一下这个程序的核心需求避免写着写着跑偏。一个合格的自动出题判分程序至少要满足这几点能随机生成题目、能接收用户输入、能判断答案对错、能统计得分、能支持至少两种题型比如加减法运算题和选择题。如果只做一种题型那面向对象的优势根本体现不出来因为不需要抽象。只有当你需要同时处理多种题型、且它们有共同的出题和判分行为时继承和多态才有用武之地。我在实际写这类程序时踩过最大的一个坑就是一开始把所有逻辑塞在一个Main方法里用一堆if-else判断题型。写到第三种题型的时候代码已经乱得没法看了加一个功能要改五六个地方。后来推倒重来用抽象基类把题目这个概念抽出来才真正体会到面向对象不是考试考点而是实实在在能救命的工程手段。所以这篇内容我会按照先想清楚抽象关系再动手写代码的顺序来讲而不是上来就贴一堆代码让你抄。2. 题目对象的抽象设计为什么先画关系再写代码2.1 把题目抽象成一个基类面向对象设计的第一步永远是识别有哪些对象以及它们之间是什么关系。在这个程序里最核心的对象就是题目。不管是加法题、减法题还是选择题它们都有一些共同特征都有一个题干、都有一个正确答案、都能判断用户给的答案对不对。这些共同特征就应该被提取到一个基类里。我一般会定义一个抽象类Question里面放三个东西题干文本、正确答案、以及一个抽象的判分方法。为什么用抽象类而不是接口因为题目之间除了行为一致还有一些可以共享的状态比如题干和答案字段抽象类能同时提供共享实现和强制约束两重能力比纯接口更合适。当然如果你更倾向于用接口IQuestion配合单独的实现类也完全没问题只是会多写一些重复的字段声明。public abstract class Question { public string Title { get; protected set; } public string CorrectAnswer { get; protected set; } public abstract bool CheckAnswer(string userAnswer); public abstract void Generate(); }这里Generate方法负责生成题目内容CheckAnswer负责判分。注意我把它们都设成抽象方法因为不同题型的生成逻辑和判分逻辑完全不同——加法题要随机两个数相加选择题要从选项里挑一个正确答案这些没法在基类里统一实现。2.2 继承体系怎么划分才不别扭有了基类之后具体题型就通过继承来扩展。比如我设计两个子类ArithmeticQuestion算术运算题和MultipleChoiceQuestion选择题。算术题生成两个随机数和一个运算符答案是运算结果选择题生成一个题干和四个选项答案是正确选项的编号。这里有个设计细节值得说一下算术题的运算符加、减、乘、除其实也可以进一步抽象。如果你想让程序支持多种运算可以把运算符也做成一个策略但这对于实验报告级别的程序来说有点过度设计了。我的建议是先用一个枚举或者简单的switch处理等真正需要扩展的时候再重构。过度设计是初学者最容易犯的毛病为了显得面向对象而把简单问题复杂化反而增加了理解和维护成本。继承体系确定之后整个程序的骨架就清晰了一个题目列表存放所有生成的题目循环遍历让用户作答每道题调用自己的CheckAnswer方法判分。注意这里就是多态发挥作用的地方——主循环里根本不需要知道当前题目是算术题还是选择题直接调用基类的CheckAnswer就行运行时会自动找到对应子类的实现。这就是面向对象最核心的价值之一让调用方与具体实现解耦。2.3 判分逻辑放在题目内部还是外部这是一个很容易纠结的问题判分到底应该是题目的职责还是应该单独抽一个判分器出来两种做法都有道理。把判分放在题目内部符合数据和行为放在一起的封装原则每道题最了解自己的答案格式判起来最自然。但如果判分规则很复杂比如需要考虑大小写、空格、多种等价答案放在题目里会让题目类变得臃肿。我的实际经验是对于这个程序判分逻辑放在题目内部就够了因为每种题型的判分规则都很简单。但如果你要做一个更通用的判分系统比如支持填空题的模糊匹配、支持多答案的任意匹配那就应该把判分策略抽出来用策略模式处理。判断标准很简单——如果判分逻辑会因为题型之外的原因变化就抽出来如果只跟题型有关就放在题目里。这个程序属于后者所以不抽。3. 随机出题与答案校验的代码落地细节3.1 随机数生成别小看这个Random生成随机题目离不开随机数。C#里用Random类但这里有个经典坑如果你在循环里反复new Random()由于默认种子是基于时间的快速连续创建会得到相同的种子导致生成的随机数一模一样。我见过有人的出题程序每次生成的题目都相同排查半天才发现是这个问题。正确的做法是在整个程序里只创建一个Random实例反复使用它。如果是在多线程环境下还需要考虑线程安全但对于这个单线程的控制台程序一个静态实例就够了。private static readonly Random _random new Random(); // 生成1到100之间的随机整数 int a _random.Next(1, 101); int b _random.Next(1, 101);Next方法的上界是开区间Next(1, 101)返回的是1到100这个细节很多人第一次用会搞错写成Next(1, 100)结果永远取不到100。3.2 算术题的生成与判分实现算术题的生成逻辑是随机两个操作数随机一个运算符计算出正确答案。判分时把用户输入的字符串转成数字和正确答案比较。这里要注意除法题的处理——整数除法会产生小数如果直接比较可能因为精度问题判错。我的做法是除法题只生成能整除的数对避免小数麻烦。public class ArithmeticQuestion : Question { private int _a, _b; private char _op; public override void Generate() { _a _random.Next(1, 51); _b _random.Next(1, 51); char[] ops { , -, * }; _op ops[_random.Next(ops.Length)]; int result _op switch { _a _b, - _a - _b, * _a * _b, _ 0 }; Title ${_a} {_op} {_b} ?; CorrectAnswer result.ToString(); } public override bool CheckAnswer(string userAnswer) { return userAnswer.Trim() CorrectAnswer; } }判分时用Trim()去掉用户可能误输入的空格这是个很实用的小细节。另外我故意没做除法就是为了避开小数精度和除零的问题。如果你确实想加除法记得判断除数不为零并且用能整除的数对。3.3 选择题的选项设计与判分选择题比算术题复杂一点因为要生成干扰项。我的做法是先确定正确答案再生成三个不重复的干扰项然后打乱顺序。打乱顺序这一步很多人会忘如果正确答案永远在第一个位置用户闭着眼睛选A都能得高分程序就失去意义了。public class MultipleChoiceQuestion : Question { private Liststring _options new Liststring(); public override void Generate() { int a _random.Next(2, 20); int b _random.Next(2, 20); int correct a * b; Title ${a} × {b} 等于多少; CorrectAnswer correct.ToString(); _options.Clear(); _options.Add(correct.ToString()); while (_options.Count 4) { int wrong correct _random.Next(-10, 11); if (wrong ! correct !_options.Contains(wrong.ToString()) wrong 0) _options.Add(wrong.ToString()); } // 打乱选项顺序 _options _options.OrderBy(x _random.Next()).ToList(); } public override bool CheckAnswer(string userAnswer) { if (!int.TryParse(userAnswer, out int index)) return false; if (index 1 || index _options.Count) return false; return _options[index - 1] CorrectAnswer; } }这里判分逻辑是用户输入选项编号1到4程序根据编号找到对应选项内容再和正确答案比较。用int.TryParse而不是int.Parse是为了防止用户输入非数字导致程序崩溃这是异常处理的基本意识。4. 主流程编排把各个零件组装成完整程序4.1 题目列表的生成与循环作答有了题目类之后主流程就很简单了生成N道题放进列表循环让用户作答统计得分。这里用ListQuestion存放题目注意泛型参数是基类Question但实际存的是子类对象这就是多态的经典用法。static void Main() { Console.WriteLine( 自动出题判分小程序 ); Console.Write(请输入题目数量); int count int.Parse(Console.ReadLine()); ListQuestion questions new ListQuestion(); for (int i 0; i count; i) { Question q; if (i % 2 0) q new ArithmeticQuestion(); else q new MultipleChoiceQuestion(); q.Generate(); questions.Add(q); } int score 0; for (int i 0; i questions.Count; i) { Console.WriteLine($\n第{i 1}题{questions[i].Title}); Console.Write(你的答案); string input Console.ReadLine(); if (questions[i].CheckAnswer(input)) { Console.WriteLine(回答正确); score; } else { Console.WriteLine($回答错误正确答案是{questions[i].CorrectAnswer}); } } Console.WriteLine($\n答题结束得分{score}/{count}); }这段代码里主循环完全不知道每道题的具体类型只调用基类定义的Title、CheckAnswer、CorrectAnswer。如果以后要加一种新题型只需要写一个新的子类主循环一行都不用改。这就是面向对象设计带来的可扩展性也是这个实验报告真正想让你体会的东西。4.2 输入校验与异常处理上面代码里int.Parse(Console.ReadLine())其实是有风险的如果用户输入字母就会抛异常。实际写的时候应该用int.TryParse做校验或者用try-catch包起来。我在实验报告里经常看到学生写的程序一输入非法字符就崩溃这就是缺乏异常处理意识的表现。int count; while (true) { Console.Write(请输入题目数量); if (int.TryParse(Console.ReadLine(), out count) count 0) break; Console.WriteLine(输入无效请输入一个正整数。); }这种循环直到输入合法的模式在实际开发中非常常用值得养成习惯。它比try-catch更轻量也更符合用户交互的场景。4.3 得分统计与结果展示得分统计本身很简单一个计数器就够了。但结果展示可以做得更友好一些比如显示正确率、给出评价语。这些虽然不影响核心功能但能体现程序的完整性。我在做这类小工具时习惯在最后加一个简单的评价分级比如正确率90%以上显示优秀60%以下显示需要加油让程序有点温度。5. 实测中暴露的问题与改进方向5.1 题目重复问题程序跑起来之后我发现一个明显问题随机生成的题目可能会重复。比如算术题随机到3 5两次用户会觉得程序很傻。解决办法是在生成题目时记录已生成的题干如果重复就重新生成。这个逻辑可以放在题目列表生成的那一层用一个HashSetstring记录题干重复就跳过重来。HashSetstring usedTitles new HashSetstring(); while (questions.Count count) { Question q questions.Count % 2 0 ? new ArithmeticQuestion() : new MultipleChoiceQuestion(); q.Generate(); if (usedTitles.Add(q.Title)) questions.Add(q); }HashSet的Add方法在元素已存在时返回false正好可以用来判重比手动遍历列表优雅得多。5.2 选择题干扰项可能为负数或重复前面选择题生成干扰项时我用correct _random.Next(-10, 11)来生成错误答案但没考虑到结果可能为负数或者和已有选项重复。虽然加了wrong 0和Contains判断但如果正确答案本身很小比如4可选的干扰项范围就很窄可能导致死循环。更稳妥的做法是准备一个候选池从池子里随机取取不够就扩大范围。这个坑提醒我们随机生成数据时一定要考虑边界情况尤其是当取值范围很窄的时候随机可能反复撞到已存在的值。实际开发中遇到死循环优先检查是不是随机范围太小导致的。5.3 判分逻辑的健壮性用户输入答案时可能带空格、大小写不一致、或者输入了奇怪的东西。判分逻辑要尽量宽容比如统一Trim()、忽略大小写用StringComparison.OrdinalIgnoreCase。我在判分时还遇到过用户输入全角数字的情况虽然少见但如果要做得严谨可以在判分前做一次全角转半角的处理。这些细节看起来琐碎但正是它们决定了一个程序是能跑还是好用。6. 从这个小程序能延伸出的面向对象思考写完这个程序之后我最大的感受是面向对象的价值不在于你用了多少设计模式而在于你的代码在需求变化时有多容易改。这个程序如果一开始用if-else堆出来加一种题型要改主循环、改判分逻辑、改生成逻辑牵一发动全身。而用继承体系写出来之后加题型只需要新增一个类其他代码纹丝不动。如果你想让这个程序更进一步可以尝试几个方向。一是把题目生成和判分抽成接口用依赖注入的方式组装这样测试起来更方便。二是把题目数据持久化到文件支持从题库加载而不是纯随机生成。三是加一个错题本功能把答错的题目收集起来最后统一复习。每一个方向都会逼你用到新的面向对象技巧比如接口、泛型、LINQ、序列化。我个人在实际操作中的体会是实验报告类的题目不要只满足于跑通就行多问自己一句如果需求变了我的代码要改多少。这个自问自答的过程比抄十遍代码都管用。自动出题判分这个小程序看起来简单但它是理解多态和继承的绝佳载体值得你花时间把它写扎实、写漂亮。