C++超市商品管理系统课程设计:继承多态、基类指针与文件持久化
2026/9/17 13:58:15 网站建设 项目流程

简介:C++《超市商品管理系统》课程设计报告面向高校计算机、软件工程等专业的面向对象程序设计课程学习者,可用于课程设计选题参考、报告撰写模板借鉴与期末项目复盘。文档完整梳理了超市商品管理系统的需求分析、类层次设计与算法实现,涉及商品基类与食品、化妆品、日用品、饮料四大子类的继承关系,模板机制的引入,以及购买、添加、修改、删除、按类别与厂家查询、按价格与库存量排序统计等功能模块,并配有主函数流程图、算法步骤说明和关键代码解释,报告结构遵循摘要、需求分析、系统设计、软件实现、总结的规范格式。资源包仅含1个doc文档,约524KB,代码在VC6.0环境下编译验证,便于直接参考排版与实现思路。目前已有1534人学习,适合需要快速搭建课程设计框架、理解继承与模板综合运用的初学者与进阶学生。

1. 一个把链表、继承和文件读写揉在一起的超市商品管理系统

很多人第一次拿到 C++ 课程设计题目时,第一反应是“用数组加几个 if-else 不就完了”。但这份《超市商品管理系统》的设计走的是另一条路:它要求把食品、化妆品、日用品、饮料四类商品抽出一个共同的Goods基类,再通过继承派生具体商品类,仓库统一用vector<Goods*>存放基类指针,配合infile.txt做落地存储。这套结构看起来朴素,却把面向对象里最容易踩坑的三件事一次性摆到了台面上——指针容器的内存归属、虚函数接口的调用时机、以及文件序列化的读写顺序对齐。它的定位很明确:面向对象程序设计课程的综合实践,适合已经能写独立函数、但对类层次和容器组合还停留在课本例题阶段的开发者。读通这套代码,你拿到的不是一个能商用的库存系统,而是一份把“基类指针 + 派生对象 + 文件持久化”串成完整闭环的最小样本。

2. Goods 基类与四类商品的继承体系怎么切

课程设计的类图里,Goods在最上层,下面挂FoodDrinkDaily_SuppliesCosmetics四个派生类。这个层次的切分依据是商品的“类别属性”,而不是商品的存储方式或销售方式。换句话说,如果哪天要加“家电类”,只需要再派生一个类,System里的购买、删除、查询逻辑一行都不用改——这正是把类别做成继承体系而不是做成一个category字段的真正收益。

2.1 Goods 基类的接口设计与数据成员

基类承担的是“所有商品都有什么”这件事。从报告正文看,商品共有的信息是商品名称、品牌、价格、生产厂家、库存量。对应到代码里,这些成员放在Goods中,并由一组Get_*/Set_*方法暴露给外部访问:

// goods.h —— 所有商品的抽象基类 #ifndef GOODS_H_ #define GOODS_H_ #include <string> #include <iostream> #include <fstream> using namespace std; class Goods { public: // 读取与写入文件,供 System::Read / System::Write 调用 virtual void Read(ifstream &in) = 0; virtual void Write(ofstream &out) = 0; // 控制台录入与展示,供 Add / Show 使用 virtual void Set() = 0; virtual void Show() = 0; // 基本属性的统一读写入口 string Get_Name() const { return name; } string Get_Kind() const { return kind; } double Get_Price() const { return price; } int Get_Num() const { return num; } void Set_Price(double p) { price = p; } void Set_Num(int n) { num = n; } virtual ~Goods() {} // 基类指针删除派生对象时必须虚析构 protected: string name; // 商品名称 string brand; // 品牌 string kind; // 类别:食品 / 饮料 / 日常用品 / 化妆品 string maker; // 生产厂家 double price; // 单价 int num; // 库存量 }; #endif

这里有几个点值得单独说。第一,Read/Write/Set/Show声明为纯虚函数,意味着Goods不能直接实例化,任何商品都必须落到具体类别上,这从类型层面堵住了“类别不明”的商品进入仓库。第二,Get_Name()这类访问器写成const成员函数,是为了让System::Statistics()里遍历something[i]时不会意外修改商品状态。第三,虚析构函数不是可选项——仓库用的是vector<Goods*>,一旦new Food出来的对象通过基类指针delete,没有虚析构就会只调基类析构,派生类里额外申请的字符串资源可能泄漏。

2.2 派生类怎么复用基类又注入差异

四个派生类的代码结构高度相似,以Food为例:

// food.h #ifndef FOOD_H_ #define FOOD_H_ #include "goods.h" class Food : public Goods { public: void Set() override { kind = "食品"; cout << "请输入食品名称: "; cin >> name; cout << "请输入品牌: "; cin >> brand; cout << "请输入单价: "; cin >> price; cout << "请输入生产厂家: "; cin >> maker; cout << "请输入库存量: "; cin >> num; } void Show() override { cout << "类别:" << kind << " 名称:" << name << " 品牌:" << brand << " 单价:" << price << " 厂家:" << maker << " 库存:" << num << endl; } void Read(ifstream &in) override { in >> name >> brand >> price >> maker >> num; } void Write(ofstream &out) override { out << kind << " " << name << " " << brand << " " << price << " " << maker << " " << num << endl; } }; #endif

派生类里唯一真正“不同”的地方就是kind的赋值和Show的展示顺序。这种写法看着有点重复,但课程设计阶段的取舍是对的:把差异显式写在每个派生类里,比在基类里塞一堆if (kind == "食品")更符合“多态”的训练目标。要注意的是ReadWrite的字段顺序必须严格对齐——Write里写的是“类别 名称 品牌 单价 厂家 库存”,Read里就必须按同样顺序读,否则infile.txt一旦跨版本就会整列错位。

方法是否纯虚责任谁调用
Set()控制台交互录入一条商品System::Add()
Show()格式化输出一条商品购买/查询/统计
Read(ifstream&)从文件流反序列化一条商品System::Read()
Write(ofstream&)把一条商品序列化到文件流System::Write()
Get_Num()/Set_Num()库存量的读与改购买/修改

2.3 Ask() 工厂函数与 new 出来的对象归属

System::Add()里有一段关键代码:

string kind = Ask(); // 询问用户输入类别,返回中文字符串 Goods *temp = nullptr; if (kind == "食品") temp = new Food; if (kind == "饮料") temp = new Drink; if (kind == "日常用品") temp = new Daily_Supplies; if (kind == "化妆品") temp = new Cosmetics; if (temp == nullptr) return 0; // 类别非法,直接返回 temp->Set(); // 派生类的 Set 被调用 int a = Have(temp->Get_Kind(), temp->Get_Name()); if (a == -1) { something.push_back(temp); // 归属权移交给 vector } else { delete temp; // 已存在,必须手动释放 return 0; } return 1;

这段代码是整份设计的核心模式:用Ask()把“类别字符串”翻译成“派生类指针”,再由push_back完成所有权转移。很多刚接触多态指针容器的同学容易在这里漏掉delete temp,结果是每次重复添加商品就泄漏一个派生类对象。另一个常见误用是在Ask()里直接返回Goods对象而不是指针,那样会发生对象切片,派生类里新增的成员和重写的虚函数全部丢失,something.push_back进去的全是基类副本。

3. vector + infile.txt:仓库存储与持久化的取舍

这份设计没有用链表,而是用vector<Goods*>做仓库。报告里提到“使用的了数据结构当中的链表结构”,但从给出的Read/Write代码看,实际容器是vectorerase(something.begin() + a)something[i]->Write(out)都是 vector 的典型用法。这不影响功能,反而更安全——vector 的随机访问让Have()返回下标后可以直接定位,不需要维护链表指针。

3.1 为什么用指针容器而不是对象容器

如果写成vector<Goods> something,那么所有商品都只能是Goods类型。C++ 值语义不支持多态,push_back(new Food)会触发对象切片,Show()永远调基类版本。所以这里必须用vector<Goods*>,让容器里存的是地址,虚函数表指针完整保留。

代价是内存管理必须自己扛。常见做法是在System的析构函数里统一清理:

// system.h 中 System 的析构函数 System::~System() { for (size_t i = 0; i < something.size(); ++i) { delete something[i]; // 释放每个派生类对象 } something.clear(); }

如果你用 C++11 及以后的环境,可以把容器换成vector<unique_ptr<Goods>>push_backstd::move转移,erase时对象自动析构。但课程设计里通常不允许改动核心结构,所以老老实实写好析构才是稳妥方案。

3.2 Read / Write 的序列化协议

持久化部分就是一段极简的纯文本协议。Write先写商品条数,再逐条写商品;Read先读条数,再循环读入:

void System::Write() { ofstream out; out.open("infile.txt"); if (!out.is_open()) return; // 文件打不开直接返回,避免写出空文件 out << something.size() << endl; // 第一行:商品总条数 for (size_t i = 0; i < something.size(); ++i) { something[i]->Write(out); // 每条商品调各自派生类版本 } out.close(); } void System::Read() { ifstream in; in.open("infile.txt"); if (!in.is_open()) return; // 首次运行没有文件,正常 int count = 0; in >> count; // 先读条数 for (int i = 0; i < count; ++i) { string kind; in >> kind; // 读类别,决定 new 哪个派生类 Goods *temp = nullptr; if (kind == "食品") temp = new Food; if (kind == "饮料") temp = new Drink; if (kind == "日常用品") temp = new Daily_Supplies; if (kind == "化妆品") temp = new Cosmetics; if (temp) { temp->Read(in); // 派生类自己读剩下的字段 something.push_back(temp); } } in.close(); }

这里有一个必须注意的协议细节:Write时每条商品的第一个字段是kindRead时也必须先单独读kind,用它决定new哪个类,再把流交给派生类的Read读剩余字段。如果你把kind放进派生类的Read里读,和工厂判断逻辑就会打架。

文件位置字段类型说明
第 1 行countint商品总条数,决定 Read 循环次数
后续每行kindstring类别标识,用于工厂 new
同行namestring商品名称
同行brandstring品牌
同行pricedouble单价
同行makerstring生产厂家
同行numint库存量

提示:字段之间用空格分隔,所以商品名称、品牌、厂家都不能含空格。如果实际业务里出现“可口可乐 罐装”这种带空格的名称,这套纯文本协议会直接错列,需要换成带长度前缀或 CSV 转义。

3.3 Have() 线性查找与去重逻辑

Have(kind, name)承担了几乎全部功能模块的前置校验,返回下标或 -1:

int System::Have(string kind, string name) { for (size_t i = 0; i < something.size(); ++i) { // 类别和名称同时匹配才算命中 if (something[i]->Get_Kind() == kind && something[i]->Get_Name() == name) { return static_cast<int>(i); } } return -1; }

复杂度是 O(n),在几百条商品规模下完全够用。它的价值在于把“存在性判断”收敛到一个函数里:购买前查它、添加前查重、删除前查它、修改前查它、查询前查它。五处调用点的行为因此保持一致。常见的误用是只在添加时查重,删除时不查就直接erase,结果按下标删掉了另一条商品。

4. 六大功能函数的实现细节与返回值约定

六个功能函数——BuyAddDelectModefiedFindStatistics——在主菜单里被Star()分发。它们之间的协作不是各自为政,而是围绕Have()something容器形成了一套稳定的返回值约定。

4.1 购买函数的库存扣减与边界判断

购买逻辑先把“商品存在”和“库存充足”两个条件分开判断:

int System::Buy() { string kind = Ask(); string name; cout << "请输入购买商品名称: "; cin >> name; int a = Have(kind, name); if (a == -1) return 0; // 商品不存在 something[a]->Show(); // 显示商品信息供确认 int buy_num; cout << "请输入购买数量: "; cin >> buy_num; if (buy_num <= something[a]->Get_Num()) { int left = something[a]->Get_Num() - buy_num; something[a]->Set_Num(left); // 扣减库存 cout << "本次花费: " << buy_num * something[a]->Get_Price() << " 元" << endl; return 1; } return -1; // 库存不足 }

返回值三态:1表示购买成功,0表示商品不存在,-1表示库存不足。主菜单根据返回值给不同提示。这套约定在六个函数里基本一致,是课程设计中值得保留的习惯。容易忽略的是buy_num本身没有做“必须大于 0”的校验,用户输入0或负数时库存会被反向增加。补一行if (buy_num <= 0) return -1;就能堵住。

4.2 删除与修改的下标稳定性

删除用的是erase

int System::Delect() { string kind = Ask(); string name; cout << "请输入要删除的商品名称: "; cin >> name; int a = Have(kind, name); if (a != -1) { delete something[a]; // 先释放对象,防止泄漏 something.erase(something.begin() + a); // 再移除指针 return 1; } return 0; }

顺序不能反。如果先erasedelete,指针已经不在容器里,你手上的a下标也已经失效。另一个坑是erase之后所有后续元素下标前移,如果外部缓存了旧下标,必须同步失效。修改函数不涉及下标变化,只改字段,相对安全:

int System::Modefied() { string kind = Ask(); string name; cout << "请输入要修改的商品名称: "; cin >> name; int a = Have(kind, name); if (a == -1) return 0; int choice; cout << "1.修改价格 2.修改库存" << endl; cin >> choice; if (choice == 1) { double price; cin >> price; something[a]->Set_Price(price); } else if (choice == 2) { int num; cin >> num; something[a]->Set_Num(num); } return 1; }

4.3 统计排序与 operator< 的方向坑

统计函数把商品的关键字段抽到item结构体里,再排序输出:

struct item { string name; double price; int num; }; // 全局 operator< 决定 sort 的排序依据 bool operator<(const item &g1, const item &g2) { return g1.num < g2.num; // 按库存从小到大 }

这里有一个和原始需求不一致的地方:任务书要求“按从大到小排序”,而这份代码的operator<和报告正文都写成从小到大。要改成从大到小,最直接的做法是把比较方向反过来:

bool operator<(const item &g1, const item &g2) { return g1.num > g2.num; // 语义上是"小于",效果上是降序 }

或者保留原比较器,在调用sort时传入反向迭代器:

sort(st.rbegin(), st.rend()); // 结果数组整体倒序
统计维度原始字段排序比较器输出列
价格统计item.price按 price 比较名称 + 价格
库存统计item.num按 num 比较名称 + 库存
全量统计全部字段不排序全部详细信息

注意:operator<一旦定义成按num比较,价格统计就不能复用它。稳妥写法是不定义全局比较器,改为在Statistics()内部用 lambda 指定比较字段,避免两个统计维度互相干扰。

统计里还有一处边界问题:for (unsigned int i = 0; i < something.size(); i++)里打印st[i],如果某次stsomething长度不一致(比如中途有商品被删),就会越界。统计前先重新填充st,且打印循环用st.size()作为上界才是正确写法。

5. 从 VC6.0 迁到现代编译器的兼容处理与验证

这份代码的原生环境是 VC6.0,它对 C++ 标准的支持停留在 1998 年之前。把同样的源码丢进 g++ 或 Visual Studio 2019+ 时,有几类报错几乎必然出现,处理方式可以直接抄。

5.1 头文件与命名空间的适配

VC6.0 时代常用<iostream.h>这种带.h的写法,现代编译器必须改成:

#include <iostream> // 不是 <iostream.h> #include <fstream> #include <vector> #include <string> #include <algorithm> // sort 需要 using namespace std;

VC6.0 的for循环变量作用域是函数级的,for (int i = 0; ...)之后还能访问i。标准 C++ 里i只属于循环体。如果后续代码引用了循环外的i,编译会报未声明,需要把变量提到循环外。

5.2 override 与虚析构的处理

老代码里虚函数重写没有override关键字。加上它能让编译器帮你检查签名是否真的匹配:

class Food : public Goods { public: void Set() override; // 签名不符时编译器直接报错 void Show() override; void Read(ifstream &in) override; void Write(ofstream &out) override; };

同时确保Goods的析构函数声明为virtual。如果漏了,用-Wall编译时会提示delete非虚析构类的派生对象,这是真实存在的未定义行为。

5.3 用一组固定用例验证读写一致性

编译通过只是第一步,持久化协议最容易在“写进去再读出来”这个来回中出问题。建议准备一组最小验证用例:

# 1. 编译(假设所有头文件和源文件在同一目录) g++ -std=c++11 -Wall -o market main.cpp system.cpp # 2. 首次运行,文件不存在,应进入空仓库 ./market # 3. 手动造一条最小合法数据 printf "1\n食品 可乐 可口可乐 3.5 可口可乐公司 100\n" > infile.txt # 4. 再次运行,检查统计功能能否读出这条记录 ./market

运行后重点看三件事:统计输出里的商品名称、单价、库存是否和文件里的数值一致;购买一次后再退出,infile.txt第一行条数是否仍为 1、库存是否减少;连续添加两条同名同类别商品,第二条是否被Have()拦截而不会写入文件。这三步覆盖了序列化对齐、库存扣减落盘、以及去重逻辑三条最容易出错的路径。如果第二步行数变成了 2 而文件里应该只有 1 条,多半是Write之前没有对容器去重,或者Read时因为字段错位重复 push 了对象。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询