ARTICLE DETAIL

资讯详情

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

C++面向对象实验报告:从封装继承多态到内存管理的综合实践

C++面向对象实验报告:从封装继承多态到内存管理的综合实践 1. 项目概述一次面向对象思想的深度实践又到了交实验报告的时候了。这次是C面向对象的第四次实验很多同学可能觉得实验报告就是走个过场把代码一贴运行结果一截图就完事了。但在我看来每一次实验报告尤其是面向对象这种核心课程的中后期实验都是一次对前期所学知识的系统性检验和深度串联。它不再是让你简单地写个类、继承一下而是要求你综合运用封装、继承、多态、运算符重载、模板等特性去解决一个相对完整的、模拟现实世界的小型项目。这次实验很可能就是让你设计一个“学生成绩管理系统”、“图书管理系统”或者“简单的图形绘制程序”之类的。核心不在于业务逻辑有多复杂而在于你如何用纯粹的、优雅的面向对象思想来建模和实现。对于初学者来说从面向过程的C语言思维切换到面向对象的C思维这个坎儿并不容易跨过。你可能会纠结这个属性到底该放在基类还是派生类这里用虚函数还是普通函数拷贝构造函数要不要写深拷贝运算符重载的返回值类型怎么定这些问题在理论课上听起来头头是道但一到自己动手写代码各种编译错误和运行时bug就会让你焦头烂额。这份实验报告的价值就在于逼迫你去面对这些具体的问题并通过调试和思考把书本上的概念变成自己肌肉记忆的一部分。接下来我就结合常见的实验题目类型拆解一下完成一份高质量C面向对象实验报告需要关注的核心要点、容易踩的坑以及如何让你的代码和报告脱颖而出。2. 实验核心需求与设计思路拆解2.1 典型实验题目分析与建模第四次实验的题目通常不会是一个孤立的语法点练习而是一个综合性项目。我们以一个经典的“学校人员信息管理系统”为例进行拆解。系统需要管理学生和教师的信息学生有学号、姓名、班级、多门课程成绩教师有工号、姓名、部门、所授课程。要求能实现信息的增删改查、成绩统计如计算学生平均分、教师所授课程门数、以及信息的显示。面向对象建模思路识别类Class这是最核心的一步。我们不能一上来就写Student和Teacher两个独立的类然后复制粘贴一大堆相同的代码如姓名、ID的输入输出。正确的思路是寻找共性抽象出基类。学生和教师都是“人”Person他们都具有姓名、ID等公共属性以及显示信息等公共行为。因此我们首先应该设计一个Person基类。设计类层次结构Inheritance让Student类和Teacher类公开继承public inheritance自Person类。这体现了“是一个is-a”的关系学生是一个人教师也是一个人。继承使得公共代码得以复用。确定成员变量与成员函数EncapsulationPerson类保护成员protectedname_姓名、id_编号。公共接口包括构造函数、获取姓名/ID的函数、以及一个虚函数virtual void display() const用于显示信息。这里用虚函数是为多态做准备。Student类新增私有成员class_班级、scores_成绩可以用vectordouble存储。需要重写overridedisplay函数除了显示基类信息还要显示班级和成绩。增加计算平均分getAverage()的成员函数。Teacher类新增私有成员department_部门、courses_所授课程可以用vectorstring存储。同样重写display函数并增加获取课程数量的函数。考虑内存管理与资源管理如果类中有指针成员比如动态数组存储成绩就必须考虑“三大件”析构函数、拷贝构造函数、拷贝赋值运算符。这是实验的难点和易错点也是区分代码质量的关键。设计程序流程与数据结构主函数中通常使用一个容器如vectorPerson*来统一管理所有人员对象的指针。利用基类指针指向派生类对象并通过虚函数机制实现多态调用这是面向对象编程的精华所在。2.2 关键设计决策与背后的“为什么”为什么用protected而不是private继承public继承是最常用的它严格遵循“is-a”关系派生类对象可以当作基类对象使用基类的public和protected成员在派生类中保持原有访问权限。private继承表示“根据…实现”是一种“has-a”关系的替代方案但会切断派生类对象到基类的隐式转换在本次实验场景中几乎用不到。protected继承更为罕见。所以无特殊说明一律使用public继承。为什么display()要声明为虚函数如果display()不是虚函数当我们通过一个Person*指针指向一个Student对象并调用p-display()时调用的将是Person::display()而不是Student::display()这就无法实现“一个接口多种形态”的多态行为。声明为虚函数后程序会在运行时根据指针实际指向的对象类型来调用正确的display函数。通常基类中预期会被派生类重写的函数都应声明为虚函数。如果该类不希望被继承则不应有虚函数。为什么使用vectorPerson*而不是vectorPerson这是理解多态和对象切片Object Slicing的关键。如果存储Person对象当你将一个Student对象存入vectorPerson时会发生对象切片vector只复制了Student对象中属于Person基类的那部分数据派生类特有的部分班级、成绩被“切”掉了。这个对象从此就只是一个Person对象丢失了所有Student的特性多态完全失效。存储指针最好是智能指针如vectorshared_ptrPerson但实验可能要求原生指针可以避免切片指针指向堆上完整的派生类对象从而通过虚函数表实现正确的多态行为。3. 核心代码实现与难点解析3.1 基类与派生类的定义// Person.h - 基类头文件 #ifndef PERSON_H #define PERSON_H #include string #include iostream class Person { public: // 构造函数 Person(const std::string name, const std::string id); // 虚析构函数至关重要确保通过基类指针删除派生类对象时能正确调用派生类析构函数 virtual ~Person() {} // 获取基本信息 std::string getName() const { return name_; } std::string getId() const { return id_; } // 纯虚函数使Person成为抽象基类无法实例化强制派生类实现display virtual void display() const 0; protected: // 派生类需要访问这些成员 std::string name_; std::string id_; }; #endif // PERSON_H// Student.h - 派生类头文件 #ifndef STUDENT_H #define STUDENT_H #include Person.h #include vector class Student : public Person { // public继承 public: Student(const std::string name, const std::string id, const std::string class_name); ~Student() override default; // C11 override关键字显式声明重写 void addScore(double score); double getAverage() const; void display() const override; // 重写基类虚函数 private: std::string class_; std::vectordouble scores_; // 使用vector管理动态数组避免手动内存管理 }; #endif // STUDENT_H关键点解析头文件保护符#ifndef、#define、#endif是防止头文件被多次包含的经典方法必须要有。虚析构函数基类的析构函数必须声明为虚函数。想象一下如果Person的析构函数不是虚函数当你delete一个Person*指针实际指向Student对象时只会调用Person的析构函数而Student中vector的析构不会被调用虽然vector会自己析构但如果Student有手动new的资源就会泄漏。这是一个非常隐蔽的bug。纯虚函数与抽象类virtual void display() const 0;将display声明为纯虚函数这使得Person成为一个抽象基类Abstract Base Class, ABC。你不能创建Person的对象Person p;会编译错误这从设计上强制要求你必须创建派生类对象保证了多态体系的完整性。这是一种良好的设计习惯。override关键字C11在派生类重写虚函数时加上override是极好的习惯。它让编译器帮你检查这个函数是否真的成功重写了基类的虚函数函数签名是否完全一致。如果基类没有对应的虚函数或者你写错了函数名、参数类型、常量性编译器会报错帮你提前发现错误。3.2 “三大件”的深入探讨以深拷贝为例假设我们的Student类不是用vector而是用原始指针和动态数组来存储成绩虽然不推荐但实验可能会考察那么“三大件”就必须显式定义。// 一个“危险”的Student类示例用于讲解 class StudentDangerous : public Person { public: StudentDangerous(const std::string name, const std::string id, int score_capacity); ~StudentDangerous(); // 需要释放 scores_ // 拷贝构造函数 StudentDangerous(const StudentDangerous other); // 拷贝赋值运算符 StudentDangerous operator(const StudentDangerous other); void addScore(double score); void display() const override; private: double* scores_; // 原始指针指向动态数组 int capacity_; int size_; };实现拷贝构造函数深拷贝StudentDangerous::StudentDangerous(const StudentDangerous other) : Person(other), // 重要先调用基类的拷贝构造函数初始化基类部分 capacity_(other.capacity_), size_(other.size_) { scores_ new double[capacity_]; // 分配新内存 for (int i 0; i size_; i) { scores_[i] other.scores_[i]; // 复制数据 } std::cout 拷贝构造函数被调用 std::endl; }实现拷贝赋值运算符拷贝赋值运算符要处理自赋值问题并且通常采用“拷贝-交换” idiom 来保证异常安全。StudentDangerous StudentDangerous::operator(const StudentDangerous other) { if (this other) { // 1. 检查自赋值 return *this; } // 2. 分配新资源前先释放旧资源 delete[] scores_; // 3. 复制基类部分 Person::operator(other); // 重要显式调用基类的赋值运算符 // 4. 复制派生类成员 capacity_ other.capacity_; size_ other.size_; scores_ new double[capacity_]; for (int i 0; i size_; i) { scores_[i] other.scores_[i]; } std::cout 拷贝赋值运算符被调用 std::endl; return *this; }注意事项基类部分的处理在拷贝构造函数初始化列表中调用基类拷贝构造函数在拷贝赋值运算符中显式调用基类赋值运算符这是确保派生类对象完整复制的关键极易被遗忘。自赋值检查a a;如果没有检查delete[] scores_会把自己需要的数据也删掉导致未定义行为。异常安全上面的赋值运算符在new失败时对象会处于一个尴尬的状态旧资源已释放新资源未获得。更健壮的做法是“拷贝-交换”Copy-and-Swap先创建一个局部副本再交换。对于实验来说理解基本流程更重要。现代C的解决方案实际上在真实项目中你应该尽量避免手动管理内存。使用std::vector等STL容器它们自己处理好了拷贝、赋值和析构你就不需要自己写“三大件”了这就是所谓的“Rule of Zero”。实验要求你写是为了理解背后的原理。3.3 多态与容器的使用主函数中如何组织和管理这些对象是多态的集中体现。#include vector #include memory // 为了使用智能指针 #include “Person.h” #include “Student.h” #include “Teacher.h” int main() { // 使用原始指针实验常见要求 std::vectorPerson* people; people.push_back(new Student(“张三”, “2023001”, “计算机一班”)); people.push_back(new Teacher(“李老师”, “T001”, “计算机学院”)); people.push_back(new Student(“王五”, “2023002”, “计算机二班”)); // 多态调用遍历容器统一调用display for (auto p : people) { p-display(); // 根据p实际指向的对象类型调用Student::display或Teacher::display std::cout “-------------------” std::endl; } // 重要释放内存 for (auto p : people) { delete p; } people.clear(); // 更推荐的做法使用智能指针避免手动delete和内存泄漏 std::vectorstd::shared_ptrPerson safe_people; safe_people.push_back(std::make_sharedStudent(“张三”, “2023001”, “计算机一班”)); safe_people.push_back(std::make_sharedTeacher(“李老师”, “T001”, “计算机学院”)); // 无需手动释放内存当safe_people离开作用域时所有资源会自动释放 return 0; }关键点解析多态的威力for循环中的p-display()是点睛之笔。我们不需要知道p具体指向的是学生还是老师只需要知道它是一个Person并且能display自己。具体的显示行为由对象自己决定。这极大地降低了模块间的耦合度增加新的人员类型如Staff职员时主循环的代码完全不需要修改。内存泄漏风险使用原始指针必须手动delete否则会造成内存泄漏。这是一个常见的扣分点。务必在程序结束前遍历容器释放所有对象。智能指针是更好的选择std::shared_ptr是引用计数智能指针。使用std::make_shared创建对象并交给shared_ptr管理当最后一个指向该对象的shared_ptr被销毁时对象会自动被删除。这几乎完全避免了内存泄漏的问题。即使实验不要求在报告里提一下这一点也能体现你的知识面。4. 实验报告撰写要点与排错实录4.1 报告结构与内容组织一份好的实验报告代码只是其中一部分。报告本身应该清晰地反映你的思考过程。实验目的不要照抄实验指导书。用自己的话简述例如“本次实验旨在综合运用C面向对象的封装、继承、多态三大特性以及拷贝控制、运算符重载等机制设计并实现一个简易的学校人员信息管理系统加深对面向对象程序设计思想的理解。”实验环境写明操作系统Windows 10/11, Ubuntu 22.04等、编译器及版本g 11.3.0, MSVC 19.32等、开发工具VS Code, CLion, Visual Studio 2022等。系统设计类图UML用文字描述或画一个简单的类图。例如“本系统设计了一个抽象基类Person包含姓名和ID属性及显示接口。Student类和Teacher类公有继承自Person类并分别扩展了班级/成绩和部门/课程属性及相应方法。”关键数据结构说明主程序中用什么容器管理对象vectorPerson*并解释为什么。核心代码分析不要贴全部代码。挑出最核心、最能体现你理解的片段进行展示和讲解。基类设计展示Person类头文件重点解释虚析构函数和纯虚函数的作用。继承与多态展示Student::display()的重写以及主函数中通过基类指针调用的循环。难点实现如果你实现了深拷贝“三大件”或运算符重载把这段代码贴出来并详细解释每一步在做什么为什么要这样处理如自赋值检查、先释放旧资源。运行结果截图截取控制台有代表性的输出证明程序功能正确。实验总结与思考这是报告的精华也是拉开差距的地方。不要写“我学会了面向对象”。要写具体的收获和困惑收获“通过本次实验我深刻理解了将共性抽象到基类带来的代码复用好处。在使用vectorPerson*管理异构对象集合时真正体会到了多态如何让代码更简洁、更易扩展。在实现深拷贝赋值运算符时对自赋值问题和异常安全有了更直观的认识。”遇到的问题与解决“在最初设计时我曾将Person的display设为非虚函数结果所有对象都只显示‘Person’信息。通过调试和学习明白了虚函数表是实现运行时多态的关键。另外在释放内存时忘记了遍历delete导致内存泄漏使用Valgrind工具检测后得以修正。”改进设想“目前使用原始指针管理生命周期存在风险。在实际项目中应使用std::unique_ptr或std::shared_ptr等智能指针。此外可以引入文件操作将人员信息持久化存储到磁盘。”4.2 常见编译与运行时错误排查**‘undefined reference tovtable for ...’** 链接错误通常是因为虚函数没有定义特别是纯虚函数。检查你是否在.cpp文件中实现了所有声明过的虚函数非纯虚函数或者是否忘记将包含虚函数实现的.cpp文件加入编译。对象切片Object Slicing 当你把派生类对象按值传递给一个接受基类对象的函数或者用派生类对象初始化一个基类对象时派生类特有的部分会丢失。症状是通过基类对象调用虚函数没有发生多态行为。解决方案始终使用指针或引用来传递多态对象。内存泄漏Memory Leak 程序运行后系统内存使用持续增长对于小程序可能不明显。排查工具在Linux下可以使用Valgrind在Windows下可以使用Visual Studio的诊断工具。预防养成“谁new谁delete”的配对思维或者直接使用智能指针。纯虚函数调用错误 如果抽象基类Person的构造函数或析构函数中调用了纯虚函数display()会导致运行时错误。因为此时派生类对象尚未构造完成或已被部分销毁虚函数表可能不完整。规则避免在构造函数和析构函数中调用虚函数。拷贝赋值运算符的自赋值问题 如前所述a a;如果没有检查会导致灾难。测试在代码中故意加入自赋值语句测试你的赋值运算符是否能正确工作。4.3 让代码更专业的几个小技巧使用初始化列表在构造函数中尽量使用成员初始化列表来初始化成员变量而不是在构造函数体内赋值。这对于常量成员、引用成员以及类类型成员特别是没有默认构造函数的是必须的并且效率更高。// 推荐 Student::Student(const string name, const string id, const string cls) : Person(name, id), class_(cls) { // 初始化列表 scores_.reserve(10); // 如果需要可以在函数体内做其他操作 } // 不推荐 Student::Student(const string name, const string id, const string cls) { name_ name; // 这是赋值不是初始化 id_ id; class_ cls; }为派生类添加override和finalC11的override关键字能防止笔误final关键字可以防止类被进一步继承或虚函数被进一步重写增加代码的清晰度和安全性。使用const正确性对于不修改成员变量的成员函数一律声明为const如getName(),getAverage(),display()。这既是良好的设计习惯也能让代码更易理解有时还能使编译器做更多优化。头文件只包含必要内容在.h文件中使用前向声明forward declaration来减少编译依赖。只在.cpp文件中包含实际需要的头文件。这能显著加快大型项目的编译速度。完成这样一份实验报告的过程远比复制粘贴代码要耗时费力但你的收获也是成倍的。你会对C面向对象的内部机制如虚函数表、对象内存布局有更感性的认识对如何设计出松耦合、易扩展的代码有初步的体会。这些经验将是你在后续学习数据结构、设计模式乃至进行实际项目开发时宝贵的财富。
返回列表