C#面向对象实战:从实验报告到自动出题判分小程序
1. 从实验报告到能跑的小工具这个项目到底在做什么《C#语言程序设计》这门课几乎每个计算机相关专业的学生都绕不过去。而实验报告里的“面向对象程序设计”这一章往往是很多人第一次真正把类、对象、继承、多态这些概念串起来用的地方。但大部分人的实验报告是怎么做的定义一个Shape基类派生Circle、Rectangle算个面积周长截图交差。说实话这种练习做完就忘因为太抽象了跟真实能用的东西之间隔着一道鸿沟。我这次要聊的是一个把“面向对象实验”真正做成一个能用的自动出题判分小程序的完整思路和实现过程。它不是那种交完作业就删掉的代码而是一个你做完之后会愿意留在电脑里、甚至拿去给学弟学妹用的东西。核心功能很直白程序自动生成题目用户作答程序自动判分并给出反馈。听起来简单但里面涉及的东西一点都不少——题目模型怎么设计、判分逻辑怎么抽象、界面怎么组织、状态怎么管理每一个环节都是面向对象思想的实战演练。这个项目适合谁如果你正在学C#刚学完类和对象想找一个能把知识点全部串起来的练手项目那它非常合适。如果你已经工作了想回头补一补面向对象设计的基本功它同样有价值因为“出题-作答-判分”这个业务模型天然适合用来练习继承、多态、接口、封装这四大支柱。我下面会从设计思路、核心细节、实操过程、问题排查四个维度把这个项目彻底拆开讲清楚代码可以直接参考复现。先说一下最终成品大概长什么样控制台版本为主因为实验报告场景下控制台最稳妥不依赖任何UI框架老师一看就懂。程序启动后用户选择题目类型比如选择题、判断题、填空题程序从题库中随机抽题展示用户输入答案程序逐题判分最后给出总分和错题回顾。整个流程闭环麻雀虽小五脏俱全。2. 整体架构设计为什么这么拆类2.1 需求拆解与核心对象识别拿到“自动出题判分”这个需求第一件事不是写代码而是做名词提取。你把需求描述里出现的名词圈出来基本上就是你要设计的类。我圈出来的核心名词有题目、选项、答案、试卷、判分器、用户。再往下想一层题目还分类型——选择题有选项判断题只有对错填空题需要文本匹配。这就自然引出了继承体系。我的设计是这样的一个抽象基类Question封装所有题目共有的属性题干、正确答案、分值和行为展示题目、检查答案。然后ChoiceQuestion、JudgeQuestion、FillBlankQuestion三个子类分别实现各自的展示和判分逻辑。这里用抽象方法而不是虚方法因为每种题目的展示和判分方式完全不同基类没法给出一个有意义的默认实现。判分这块我单独抽了一个Scorer类而不是把判分逻辑塞在试卷里。为什么因为判分规则未来可能变——比如填空题要不要忽略大小写、要不要支持多个正确答案、选择题是单选还是多选。把判分逻辑独立出来符合单一职责原则改判分规则不会影响到题目本身的结构。试卷用ExamPaper类表示内部持有一个ListQuestion。注意这里用的是基类类型的列表实际存的是子类对象这就是多态的经典用法。遍历列表调用Display()和Check()的时候运行时会自动找到对应子类的实现不需要写一堆if-else去判断题目类型。这一点是这个项目最核心的设计价值也是实验报告里最值得写的一段。2.2 继承体系的设计取舍很多人做这个项目的时候容易犯一个错误把所有题目类型塞进一个类用QuestionType枚举来区分然后展示和判分的时候写一大堆switch。这种写法能跑但完全违背了面向对象的设计初衷而且每加一种新题型就要改所有switch分支维护成本极高。用继承体系的好处是开闭原则——对扩展开放对修改关闭。你以后想加一种“连线题”或者“排序题”只需要新建一个子类继承Question实现自己的Display()和Check()然后在题库加载的地方注册一下就行其他代码一行都不用动。这个优势在小项目里可能感受不明显但它是面向对象思维的核心实验报告里把这个点讲清楚分数不会低。不过继承也不是没有代价。子类多了之后如果基类设计得不好改基类可能会影响所有子类。所以基类的职责要尽可能纯粹只放真正共有的东西。我一开始在基类里放了一个Difficulty属性后来发现判断题和填空题根本用不上难度分级就把它挪到了需要它的子类里。这个调整过程本身就是很好的设计经验。2.3 数据存储方案的选择题库数据存哪里这是实操中必须面对的问题。我见过三种常见做法硬编码在代码里、存文本文件、存JSON或XML。硬编码最省事但题目一多代码就臃肿得没法看而且改题目要重新编译。文本文件解析起来麻烦格式容易出错。我最终选的是JSON用System.Text.Json序列化和反序列化C#内置支持不需要引入第三方库。JSON的结构也很直观一个数组每个元素是一道题用type字段区分题型。反序列化的时候有个小技巧不能直接反序列化成ListQuestion因为Question是抽象类。我的做法是先反序列化成ListJsonElement或者一个中间DTO然后根据type字段手动构造对应的子类对象。这一步是很多新手会卡住的地方后面实操部分我会给出完整代码。提示如果你用的是较老版本的.NET FrameworkSystem.Text.Json可能不可用可以换成Newtonsoft.Json用法类似但需要额外引入包。3. 核心类设计与关键代码解析3.1 抽象基类Question的完整定义基类的设计决定了整个体系的扩展性我把它写得尽量精简。核心成员包括Id题目编号、Stem题干、CorrectAnswer正确答案、Score分值。行为方面Display()负责把题目打印到控制台Check(string userAnswer)负责判断用户答案是否正确并返回布尔值。这两个方法都声明为abstract强制子类实现。public abstract class Question { public int Id { get; set; } public string Stem { get; set; } string.Empty; public string CorrectAnswer { get; set; } string.Empty; public int Score { get; set; } 10; public abstract void Display(); public abstract bool Check(string userAnswer); }这里有个细节值得说CorrectAnswer我用的是string类型而不是泛型或者object。为什么因为不管什么题型最终用户输入的都是字符串判分本质上是字符串比较或者经过一定规则处理后的比较。用string统一简化了判分器的接口设计。选择题的正确答案存选项字母如A判断题存对或错填空题存标准答案文本。统一用字符串判分器只需要一个Check(string)方法就能处理所有题型。3.2 三种题型子类的差异化实现选择题子类多了两个东西Options列表和CorrectOption。展示的时候要把选项按A、B、C、D的顺序打印出来判分的时候把用户输入转成大写后和正确答案比较。这里有个体验细节用户可能输入小写a也可能输入 A 带空格所以判分前要做Trim()和ToUpper()处理。public class ChoiceQuestion : Question { public Liststring Options { get; set; } new(); public string CorrectOption { get; set; } string.Empty; public override void Display() { Console.WriteLine($【选择题】{Stem}{Score}分); for (int i 0; i Options.Count; i) { char label (char)(A i); Console.WriteLine($ {label}. {Options[i]}); } } public override bool Check(string userAnswer) { return userAnswer.Trim().ToUpper() CorrectOption.Trim().ToUpper(); } }判断题子类最简单展示的时候在题干后面加个“对/错”判分逻辑和选择题几乎一样。填空题稍微复杂一点因为要考虑多个正确答案的情况。比如“C#中用于定义类的关键字是____”答案可能是class但用户可能输入Class或者CLASS。我的处理方式是CorrectAnswer里用竖线分隔多个可接受答案判分时逐个比较忽略大小写和首尾空格。public class FillBlankQuestion : Question { public override void Display() { Console.WriteLine($【填空题】{Stem}{Score}分); } public override bool Check(string userAnswer) { var accepted CorrectAnswer.Split(|, StringSplitOptions.RemoveEmptyEntries); var normalized userAnswer.Trim(); foreach (var ans in accepted) { if (string.Equals(ans.Trim(), normalized, StringComparison.OrdinalIgnoreCase)) return true; } return false; } }这三个子类放在一起看你会发现它们的Display()和Check()实现完全不同但对外暴露的接口完全一致。这就是多态的价值——调用方不需要知道具体是什么题型只管调用Display()和Check()就行。3.3 判分器与试卷类的协作关系ExamPaper类负责管理题目集合和答题流程。它内部有一个ListQuestion一个AddQuestion()方法往里面加题一个Start()方法启动答题循环。答题过程中每道题调用Display()展示读取用户输入调用Check()判分累计得分。注意这里完全没有出现任何if (question is ChoiceQuestion)这样的类型判断全靠多态自动分发。public class ExamPaper { private readonly ListQuestion _questions new(); public int TotalScore _questions.Sum(q q.Score); public void AddQuestion(Question q) _questions.Add(q); public void Start() { int earned 0; foreach (var q in _questions) { q.Display(); Console.Write(你的答案); string input Console.ReadLine() ?? ; if (q.Check(input)) { earned q.Score; Console.WriteLine(正确); } else { Console.WriteLine($错误正确答案是{q.CorrectAnswer}); } Console.WriteLine(); } Console.WriteLine($总分{earned}/{TotalScore}); } }Scorer类在这个版本里其实被Question.Check()吸收了因为判分逻辑和题目类型强相关放在子类里更自然。但如果你需要更复杂的判分规则比如部分给分、扣分策略可以再抽一个独立的IScoringStrategy接口出来用策略模式处理。这个扩展点我在后面会提。4. 实操过程从零到能跑的完整步骤4.1 项目创建与JSON题库准备打开Visual Studio或者直接用dotnet命令行创建一个控制台项目。命令行的话就三行dotnet new console -n AutoQuiz cd AutoQuiz dotnet run项目建好之后在项目根目录新建一个questions.json文件。注意要设置文件的“复制到输出目录”属性为“如果较新则复制”否则运行时找不到文件。JSON的结构我设计成这样[ { type: choice, id: 1, stem: C#中用于声明常量的关键字是, options: [const, static, readonly, final], correctOption: A, score: 10 }, { type: judge, id: 2, stem: C#支持多重继承。, correctAnswer: 错, score: 10 }, { type: fill, id: 3, stem: C#中所有类的基类是____。, correctAnswer: object|Object|System.Object, score: 10 } ]这个结构的好处是自描述性强加题目只需要往数组里追加对象不需要改任何代码。type字段是反序列化时的分发依据correctAnswer用竖线分隔多个可接受答案填空题判分时自动处理。4.2 反序列化与工厂方法实现前面说了不能直接反序列化成ListQuestion。我的做法是定义一个中间类QuestionDto字段和JSON一一对应然后写一个工厂方法根据type构造具体子类。public class QuestionDto { public string Type { get; set; } ; public int Id { get; set; } public string Stem { get; set; } ; public Liststring? Options { get; set; } public string? CorrectOption { get; set; } public string? CorrectAnswer { get; set; } public int Score { get; set; } } public static class QuestionFactory { public static Question Create(QuestionDto dto) { return dto.Type switch { choice new ChoiceQuestion { Id dto.Id, Stem dto.Stem, Options dto.Options ?? new Liststring(), CorrectOption dto.CorrectOption ?? , Score dto.Score }, judge new JudgeQuestion { Id dto.Id, Stem dto.Stem, CorrectAnswer dto.CorrectAnswer ?? , Score dto.Score }, fill new FillBlankQuestion { Id dto.Id, Stem dto.Stem, CorrectAnswer dto.CorrectAnswer ?? , Score dto.Score }, _ throw new NotSupportedException($未知题型{dto.Type}) }; } }这个工厂方法是整个数据加载的核心。switch表达式是C# 8.0之后的新语法写起来比传统switch-case简洁很多。注意每个分支里都做了空值兜底因为JSON里某些字段可能缺失直接赋null给非空属性会出问题。加载题库的代码string json File.ReadAllText(questions.json); var dtos JsonSerializer.DeserializeListQuestionDto(json, new JsonSerializerOptions { PropertyNameCaseInsensitive true }); var paper new ExamPaper(); foreach (var dto in dtos!) { paper.AddQuestion(QuestionFactory.Create(dto)); } paper.Start();PropertyNameCaseInsensitive true这个选项很重要它让JSON里的type能匹配到C#属性Type省得你手动写[JsonPropertyName]特性。4.3 随机抽题与答题流程控制上面的代码是顺序出题但“自动出题”的核心诉求是随机。改造一下ExamPaper加一个DrawQuestions(int count)方法用Random打乱顺序后取前N道。public ListQuestion DrawQuestions(int count) { var rng new Random(); return _questions.OrderBy(_ rng.Next()).Take(count).ToList(); }OrderBy(_ rng.Next())是一种常见的洗牌写法虽然严格来说不是最优的Fisher-Yates算法但对于几十道题的规模完全够用而且写起来极其简洁。如果你追求严谨可以实现标准的Fisher-Yates洗牌但对于实验报告项目来说没必要过度设计。答题流程我加了一个简单的菜单让用户选择题量和题型范围。这里用while循环加switch处理用户输入注意处理非法输入比如输入了字母而不是数字用int.TryParse而不是int.Parse避免程序崩溃。注意Random在循环里反复new会导致种子相同、随机数重复的问题。正确做法是把Random实例作为类的字段只创建一次。这个坑我在早期版本踩过题目顺序每次都一样排查了半天才发现是Random的种子问题。5. 常见问题与排查技巧实录5.1 反序列化报错与空引用异常新手做这个项目最容易卡在JSON反序列化上。最常见的报错是JsonException: The JSON value could not be converted to...原因通常是JSON格式不合法比如多了个逗号、少了引号、或者字段类型对不上。排查方法很简单把JSON贴到任意在线JSON校验工具里过一遍格式问题一目了然。另一个高频问题是空引用异常。比如dto.Options为null的时候直接调Options.Count就会崩。我的经验是所有从外部数据源来的对象使用前都要做空值检查。工厂方法里我已经做了兜底但如果你在别的地方直接访问DTO字段记得加??或者?.。还有一种情况是文件路径问题。File.ReadAllText(questions.json)用的是相对路径相对于程序的工作目录不是源代码目录。如果你在Visual Studio里调试工作目录默认是bin/Debug/netX.X/所以JSON文件必须复制到那里才能被找到。设置“复制到输出目录”就是解决这个问题的。5.2 判分逻辑的边界情况处理判分看起来简单但边界情况特别多。我整理了一个速查表覆盖了实操中遇到的大部分问题问题现象原因解决方法用户输入小写字母判错未做大小写归一化判分前统一ToUpper()或ToLower()用户输入带空格判错未做Trim处理判分前调用Trim()填空题多个答案只认一个未支持多答案用竖线分隔判分时逐个比较选择题用户输入1而非A输入格式不统一在提示中明确要求输入字母或做数字到字母的映射判断题用户输入T/F输入格式不统一在Check()里兼容多种表达如对/错/是/否/T/F判断题的兼容处理我单独说一下。用户可能输入“对”“错”“是”“否”“T”“F”“true”“false”你不可能穷举所有情况但可以做一个简单的映射表把常见表达归一化到“对”和“错”两个标准值上。这个映射逻辑放在JudgeQuestion.Check()里不影响其他题型。5.3 扩展新题型时的注意事项假设你现在想加一种“多选题”该怎么做步骤是第一新建MultiChoiceQuestion类继承Question第二实现Display()和Check()Check()里要把用户输入的多个选项排序后和正确答案比较第三在QuestionFactory的switch里加一个分支第四在JSON里加对应数据。四步搞定其他代码一行不动。但有几个坑要注意。第一多选题的正确答案存储格式要提前定好比如用逗号分隔的字母序列ABD判分时把用户输入也排序后比较。第二Display()里要提示用户“多选请输入多个字母如ABD”。第三如果用户输入了重复字母如AAB要去重后再比较。这些细节不提前想清楚加题型的时候容易返工。提示每次加新题型都要同步更新JSON题库和工厂方法两者缺一不可。我建议在工厂方法的default分支里抛一个明确的异常这样如果JSON里写了未知的type程序会立刻告诉你哪里出了问题而不是静默失败。6. 从实验报告到可展示项目的加分技巧6.1 让代码更“面向对象”的几个重构方向如果你的实验报告想拿高分光实现功能是不够的还要展示出你对面向对象思想的理解。我推荐几个重构方向每一个都能在报告里单独写一段设计说明。第一个方向是引入接口。把Display()和Check()抽到一个IQuestion接口里Question抽象类实现这个接口。这样做的好处是如果未来有某种特殊题型不需要继承Question基类比如从外部系统接入的题目只要实现IQuestion接口就能无缝融入现有体系。接口面向行为抽象类面向复用两者配合使用是面向对象设计的常见套路。第二个方向是策略模式处理判分。把判分逻辑从Question里抽出来定义IScoringStrategy接口ExactMatchStrategy、IgnoreCaseStrategy、MultiAnswerStrategy分别实现不同的判分规则。Question持有一个IScoringStrategy引用运行时可以动态替换。这样加判分规则不用改题目类符合开闭原则。第三个方向是观察者模式处理答题事件。答题过程中可以触发各种事件——答对、答错、交卷。定义一个IExamObserver接口ScoreBoard、WrongAnswerCollector等类实现它。ExamPaper在关键节点通知所有观察者。这个设计在实验报告里写出来能体现你对设计模式的实际运用能力。6.2 控制台交互的体验优化控制台程序也可以做得很好用。几个小技巧用Console.ForegroundColor给正确和错误反馈加颜色答对显示绿色答错显示红色视觉反馈立竿见影。用Console.Clear()在每道题之间清屏避免题目堆在一起看不清。加一个进度提示如“[3/10]”让用户知道还剩多少题。还有一个细节读取用户输入的时候如果用户直接按回车空输入应该提示“输入不能为空请重新输入”而不是直接判错。这个用while循环加string.IsNullOrWhiteSpace判断就能实现。这些体验优化不涉及复杂技术但能让你的项目从“能跑”变成“好用”在答辩或展示的时候加分明显。6.3 实验报告里值得展开写的设计决策实验报告不是代码堆砌老师想看的是你的思考过程。我建议重点写三个设计决策为什么用抽象类而不是接口、为什么用工厂方法而不是直接new、为什么判分逻辑放在子类而不是独立类。每个决策都写出“方案A vs 方案B”的对比说明你选A的理由和代价。比如抽象类vs接口这个点抽象类可以包含字段和实现适合有共享状态的场景接口只能定义行为适合纯契约场景。这个项目里Question有共享的Id、Stem、Score字段所以抽象类更合适。但如果未来要支持从不同数据源加载题目接口的灵活性就体现出来了。这种“看情况”的分析才是面向对象设计的精髓也是实验报告里最能体现水平的部分。最后再分享一个小心得把JSON题库做成可配置的让用户能自己加题。你可以在程序启动时加一个“题库管理”菜单支持查看题目数量、按题型筛选、甚至简单的增删改。这个功能不难但能让你的项目从“实验作业”变成“小工具”展示价值直接翻倍。我做完这个功能之后隔壁宿舍的同学都来要代码拿去练C#这种成就感是单纯交作业给不了的。