ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

面向对象综合训练实战:从学生选课系统拆解OOP核心思想

面向对象综合训练实战:从学生选课系统拆解OOP核心思想 如果现在让你接手一个别人写到一半的面向对象项目你第一件事是什么我大概率是打开源码先看类结构而不是急着运行。Day09这个节点很有意思往往前八天你把类、对象、继承、多态这些概念挨个学完到第九天突然要给一个综合训练很多人就懵了。其实面向对象综合训练要检验的不是你会不会背“封装继承多态”六个字而是你能不能把多个对象组织起来像搭积木一样完成一个真实需求。这篇内容我结合自己带练习和写小型系统的经验用“学生选课系统”这个经典题目做主线把对象建模、代码落地、多语言差异、避坑技巧都捋一遍适合正在刷练习、准备项目答辩或者想回头补OOP基础的同学。1. 面向对象综合训练到底在练什么1.1 先搞清楚OOP不是一堆概念的组合很多人在写综合练习时第一反应是把“对象、类、封装、继承、多态”这五个词背一遍然后开始敲代码。这种思路往往写到一半就卡住了因为面向对象的核心并不是这些概念本身而是你用这些概念去描述现实问题、组织代码结构的能力。说得直白点类就是一张设计图纸对象是按照图纸造出来的具体房子。你学习继承不是为了让子类去找父类要代码而是为了提炼公共特征、减少重复你学习多态不是为了炫技而是为了让调用方可以用一套代码处理不同类型的事物。我见过不少人的综合练习代码类建了七八个但大部分类里面只有getter/setter业务逻辑全部堆在main函数里。这种写法名义上是面向对象实质上还是面向过程。所以Day09这个训练真正的目标不是“实现所有功能”而是逼你重新审视一个需求哪些名词应该变成类哪些动词应该变成方法类与类之间到底是什么关系。1.2 综合练习的三种常见形式场景建模题给一段业务描述让你识别对象、设计类。这种题最考验抽象能力经常出现在笔试或面试里。代码补全题给出半成品类让你补齐构造方法、业务方法并保证类与类之间能正确协作。小型系统题从零搭建一个完整可运行的小系统比如图书管理、学生选课、员工工资结算。Day09的综合训练大多属于这一类。不管哪种形式训练的核心流程是一致的需求分析、对象识别、关系设计、编码实现、测试验证。如果再加上一条那就是代码审查自己审查或者找别人审查。很多初学者只盯着“代码跑通”这个结果实际上综合练习的评分重点往往在设计和封装上。1.3 练完之后你应该具备什么能力完成这一天的练习你至少应该具备三种能力第一看到一段需求描述能很快圈出候选类而不是着急写方法第二能说清楚两个类之间是继承、组合还是依赖关系并给出选择的理由第三能把一个不合理的类设计重构为职责更清晰的设计。这三种能力不是背出来的是靠着案例喂出来的。所以下面我直接拿一个经典题目拆给大家看。2. 一个能练透OOP的经典题目学生选课系统2.1 需求拆分名词找类动词找方法先给一个比较常见的需求描述学校需要开发一个简单的选课系统。教师可以开设课程课程有课程编号、名称、任课教师、容量上限学生可以查看课程列表选择一门课程也可以退掉已选课程选课时要检查课程是否已满以及学生是否已经选过该课程教师可以查看自己所开课程的选课学生名单。拿到这段描述不要直接开写。拿出纸笔先把名词圈出来学校、系统、教师、课程、课程编号、名称、任课教师、容量上限、学生、课程列表、选课学生名单。再圈动词开设、查看、选择、退掉、检查、统计。名词是候选类但不是所有名词都要变成类。比如“学校”在这个需求里没有具体行为“课程编号”只是课程的一个属性都不需要单独建类。真正值得做的类是学生、教师、课程外加一个专门干“选课/退课”业务的服务类。动词找方法也是一样。“开设课程”属于教师的行为“查看课程列表”更像是系统给学生的查询接口“选课”“退课”是学生触发的动作但具体规则查容量、查重复如果放在学生类里会让学生类过于臃肿。更好的做法是单独建一个选课服务类让服务类去协调学生和课程之间的交互。这就是对象建模阶段最值得练的取舍。2.2 类关系设计继承、接口、组合怎么选第一个关系学生和教师之间能不能用继承表面上学生有学号、教师有工号都有姓名和年龄公共特征是可以抽象成一个Person类然后让学生和教师继承它。但这里有一个隐藏问题学生和教师共享的只有“个人基本信息”以及“自我介绍”这类方法业务行为差异巨大。如果你为了让它们共用几个字段就硬造一个父类未来一旦需要在Person里加一个“邮箱”字段如果教师没有邮箱就会被迫写一个没意义的空实现。所以稳妥的做法是Person类做得很薄只放姓名、年龄以及一个可被重写的getInfo方法学生和教师各自维护自己的专属属性。第二个关系课程和学生之间是什么课程里维护一个学生列表学生里也可以维护一个已选课程列表。这就是组合关系。组合的优势在于对象的生命周期由整体管理课程删除时它下面的选课记录天然消失不需要额外的清理逻辑。相比之下如果让Course去继承一个List或者让Student去继承Course语义上完全说不通。第三个关系选课服务类和其它类是什么它是依赖关系它依赖Student、Course的公开接口来做业务逻辑而不应该直接访问内部属性。这里要特别提醒能用组合的地方优先用组合别一遇到共性就往上抽象。继承是手段不是目的滥用继承会导致类层级越来越深最终改一处牵一发动全身。2.3 用类图把设计钉在纸面上设计阶段可以在代码注释里画一个轻量级类图不用工具文本就够。比如这样---------------- ---------------- ------------------ | Person | | Course | | EnrollmentService | ---------------- ---------------- ------------------ | - name: string | | - courseId | | - enroll(...) | | - age: int | | - title | | - withdraw(...) | | getInfo() | | - teacher | | - listStudents() | ---------------- | - capacity | ------------------ ^ | - students[] | |继承 ---------------- ---------------- | | Student | | 组合 | - studentId |------------------ | - selected: [] | ----------------从这个图里能看出三件事一是继承关系只有一个Person被Student继承二是Course通过一个List组合了若干Student同时也可以持有Teacher引用三是业务操作集中在EnrollmentService里它不存储数据只负责改造数据。把这个图画清楚写代码的时候思路会非常顺。我实际带练习的时候发现愿意花10分钟画类图的人编码时间反而比直接写的人少三分之一。3. 从空类到可运行核心代码实现与思路3.1 实体类先解决一个对象的“基本盘”实体类是承载数据的类写法上有几个容易踩坑的点。以Python实现为例Person应该只放公共信息并提供可以被子类重写的接口class Person: def __init__(self, name: str, age: int): self._name name self._age age property def name(self): return self._name def get_info(self) - str: return f姓名{self._name}年龄{self._age}注意这里我把_name设成了私有再通过property暴露只读的name属性。为什么不让外部直接访问_name因为如果不加这一层以后要加“姓名不能为空”的校验就得把所有赋值的地方都翻出来改。用property之后再加校验只会改一个地方。很多初学者嫌这种写法麻烦实际上这是封装的核心价值把可变性收敛在类内部。接着是Student。学生除了继承Person的姓名和年龄还要有学号和已选课程列表。这里我让Student内部维护一个list并对外提供add_course和remove_course方法而不是直接把list暴露出去class Student(Person): def __init__(self, name: str, age: int, student_id: str): super().__init__(name, age) self._student_id student_id self._courses [] def add_course(self, course): if course not in self._courses: self._courses.append(course) def remove_course(self, course): if course in self._courses: self._courses.remove(course) def get_info(self) - str: return f学生{self.name}学号{self._student_id}为什么不直接让业务类去操作_courses因为Student应该对自己的选课记录负责别人只能通过add_course和remove_course来申请变更。真正判断容量和重复选课的逻辑则在服务层做两边各司其职。3.2 业务类把操作集中到服务层选课和退课是核心业务我推荐单独建一个EnrollmentService类。这个类负责检查课程容量、检查重复选课、执行选课并记录。代码可以这样写class EnrollmentService: staticmethod def enroll(student: Student, course: Course) - bool: if course.is_full(): return False if student.has_course(course): return False student.add_course(course) course.add_student(student) return True staticmethod def withdraw(student: Student, course: Course) - bool: if not student.has_course(course): return False student.remove_course(course) course.remove_student(student) return True为什么把这个逻辑放在服务类而不是Course里因为选课的规则涉及两个对象的状态课程容量和学生的已选列表。把它放在课程里课程就要知道学生的内部结构放在学生里学生就要知道课程的容量限制。无论放哪边都会破坏职责单一。独立服务类的好处是以后如果要加“选课前必须先登录”或者“同一学期最多选5门课”只需要改这一个入口。这种做事务逻辑的思路在真实后端项目里非常常见。3.3 多态应用一份代码遍历所有“人”综合练习里如果想体现多态最简单的场景是打印所有选课学生的信息。不管这个学生是普通学生还是有过特殊荣誉的学生都应该能通过统一接口get_info拿到信息。我们可以写一个打印名单的方法def print_students(course: Course): for student in course.students: print(student.get_info())这里的student.get_info()就是多态的体现。运行时它到底调用的是Person.get_info还是Student.get_info完全不依赖调用方的判断而是由实际传入的对象类型决定。我见过很多人强行用if type(student) Student来实现不同输出那样写虽然也能跑但每加一个子类你就要改一次if完全背离了多态的意义。正确做法是在子类里重写get_info然后让调用方只依赖基类的公开方法。4. 同一套OOP思想五种语言的不同表达4.1 Java和C#强类型下的“规矩编程”Java和C#的OOP语法非常相似它们都强制使用class支持继承和接口实现并且有严格的访问修饰符。以Java为例定义Person和Student的骨架大概是public abstract class Person { protected String name; protected int age; public abstract String getInfo(); } public class Student extends Person { private String studentId; Override public String getInfo() { return 学生 name 学号 studentId; } }这类语言的特点是把很多设计约定变成了语法约束。比如抽象类必须用abstract显式声明重写方法必须加Override注解访问权限有public/protected/private。写的时候会感觉啰嗦但换来的好处是运行时问题被前移到了编译期。C#在语法上更接近Java额外提供了自动属性、记录类型写法可以更简洁。4.2 Python动态语言的灵活与自我约束Python写OOP要比Java自由得多类本身不需要显式实现接口抽象基类需要额外引入abc模块。这种自由也意味着约束靠自觉。比如前面代码里的property封装在Java里是语言强制不能直接访问私有字段在Python里却只需要一个下划线做约定真要访问还是能访问到。所以Python的OOP代码好不好很大程度上取决于程序员有没有维护边界感。Python的鸭子类型是双刃剑。它能让多态不需要继承关系就能触发只要对象有get_info方法调用方就能传。这在写工具脚本时非常舒服但在大型团队项目里反而容易出问题因为缺少类型约束传错对象不会报错而是等到运行时才发现逻辑不对。所以后来的Python趋势是大量使用类型注解甚至用ABC库来定义抽象基类把一部分设计约定重新找回来。4.3 C性能与控制权背后的复杂度C的面向对象是另一套思路。它有类也有继承和多态多态依赖虚函数表并且需要手动管理对象生命周期。一个简单的Person抽象类在C里可能长这样class Person { public: virtual ~Person() default; virtual std::string getInfo() const 0; }; class Student : public Person { private: std::string studentId; public: std::string getInfo() const override { return 学生 name 学号 studentId; } };C给到开发者的控制权更大但代价是细节更多。比如析构函数必须写成虚函数否则通过基类指针删除对象时不会调用子类析构器。再比如多重继承虽然语法支持但在真实项目中经常引入菱形继承问题所以很多C编码规范会建议用组合替代多重继承。4.4 ArkTS面向对象思想在移动开发里的落地ArkTS作为鸿蒙应用开发的编程语言基于TypeScript扩展面向对象思想在它身上同样适用。它支持class、interface、extends、implements并且强调静态类型检查。一个简单的接口定义可以写成interface CourseOperation { enroll(student: Student): boolean; getStudents(): Student[]; } class EnrollmentService implements CourseOperation { enroll(student: Student): boolean { // 具体业务逻辑 return true; } getStudents(): Student[] { return []; } }ArkTS在UI层面大量使用装饰器比如Component定义组件类但组件类本质上还是class通过状态管理属性驱动页面刷新。所以在ArkTS里学OOP重点不是语法而是理解“数据模型、业务逻辑、UI状态”如何通过类去组织。如果前面Java和TypeScript的基础打得牢ArkTS学起来会非常快。4.5 五种语言OOP语法对照表语言类关键字继承关键字接口关键字访问修饰符属性封装方式Javaclassextendsimplementspublic/protected/privateprivate字段getter/setterC#class:: 接口public/protected/private属性 propertyPythonclass(父类)ABC约定 _ 前缀property装饰器Cclass: public 父类无独立接口用抽象类public/protected/privateprivate字段公开成员函数ArkTSclassextendsimplementspublic/private/protectedprivate字段存取器这张表不是为了制造焦虑而是想告诉你不同语言的OOP语法差异很大但核心思想完全一致。你只要在一个语言里把“对象建模”练明白了换语言只是换一层外壳。5. 综合练习里踩过的坑和排查技巧5.1 五个典型报错的排查思路综合练习写多了一定会碰到一些高频错误。我把最常见的五个列出来附上排查思路。空引用/NoneType错误最常见的就是调用了一个没有初始化的对象方法。排查时先看对象在哪里创建再看是否有分支没有走到初始化。建议在设计阶段就给属性明确的初始值。类型强转失败Java或C#里经常碰到把父类类型强转为子类类型时抛ClassCastException。这通常说明你的多态设计有问题应该优先检查是否该用接口或抽象方法而不是用强制类型转换。比较对象内容却用了“”在Java中“”比较的是引用地址而不是内容。判断两个课程是否相同应该重写equals和hashCode。Python里一般用“”会调用__eq__但也建议显式实现。初始化顺序混乱子类构造方法里访问了父类还没初始化的字段。解决方法是先在子类构造方法第一行调用super()再使用继承下来的字段。循环导入Python里两个文件互相import启动时直接报错。这种问题多半是设计上的耦合太高可以考虑把公共依赖抽到第三个模块去。5.2 设计上的坏味道与重构判断除了报错还有一类问题不报错但设计极差。首当其冲的就是“上帝类”一个类里既做数据存储、又做业务判断、还做输出打印看起来功能齐全实际上改动起来非常痛苦。判断标准很简单一个类的成员方法超过8个或者方法和数据之间没有明显聚类时就该考虑拆分。其次是继承层级过深。三层以内的继承还能看超过三层以后任何一次父类改动都可能影响整棵继承树。遇到这种情况优先考虑用组合替代深层继承。最后是“贫血模型”就是类里全是getter/setter没有任何业务方法。这种建模方式在综合练习里能跑但放到真实项目里会逼着业务逻辑到处散落最后变成无人区。5.3 让练习立刻有效果的检查清单每次写完综合练习对照下面清单过一遍功能完整性选课、退课、容量限制、重复选课这四个核心路径是否都测过边界条件课程容量为1时选课成功后再选容量为0时的表现学生退不存在的课是否都友好处理异常输出报错信息是否清晰还是直接抛出看不懂的堆栈封装边界类的私有属性是否真的没有被外部直接访问多态使用如果有不同类型的人是否还有if-else判断类型的行为代码可读性类名、方法名是否见名知意一段方法是否超过20行这个清单看起来简单但能把每一项都做到的人综合练习就已经超出平均水准了。6. 让一次练习的长尾价值最大化6.1 从综合练习到SOLID原则的第一步很多人以为SOLID是架构师才需要学的东西其实综合练习里已经能摸到它的影子。比如前面的选课服务类它只做选课相关的事这就是单一职责Student和Teacher继承Person增加新的人类不用改现有代码这就是开闭原则。练习之后你可以回头审视自己的代码如果现在要新增“研究生选修课”规则你的改动是不是只局限在服务层如果是说明你的设计已经具备了扩展性。这些原则不需要一次性背熟但带着它们去写练习比写十个练习再去突击原则要有效得多。6.2 三遍练习法复制、独立重构、加需求我推荐用“三遍法”来吃透一个综合训练。第一遍睁着眼睛照着参考代码敲目的是熟悉语法和整体结构敲的过程中把坏点记录下来。第二遍合上参考从需求描述开始自己重新写不追求一次写对而是看能不能画出类图并落实成一个可运行系统。第三遍给系统加一个新功能比如“按学生统计总学分”或“给课程增加开课时间”。第三遍才是真正的试金石如果加功能时你不需要大改原有类恭喜你面向对象已经入门了。如果改得焦头烂额说明第二遍的类关系设计还有问题回头重修。6.3 一个关于“写类图”的小习惯最后分享一个小习惯也是我自己带练习时反复强调的动手写代码之前先画类图画到接口、属性、方法级别。不需要漂亮自己能看懂就行。这个习惯初期会显得慢但长期收益极高。因为类图是面向对象设计的“骨架”骨架歪了后面花再多精力也只能在歪路上越走越远。等你真的去读那些开源项目你会发现最好的注释不是文档而是清晰的类结构本身。Day09只是开始把这个习惯带进后续每一个项目你才会慢慢感觉到面向对象带给你的掌控感。
返回列表