个人项目
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class78-Grade2024-CS/homework/15702 |
| 这个作业的目标 | 1. 在 GitHub 仓库中新建以学号命名的文件夹,并使用 Git 管理源代码; 2. 使用 C++ / Java / Python 实现论文查重程序,通过命令行参数读取原文与抄袭版论文,输出重复率到指定文件; 3. 完成代码质量分析、性能分析、单元测试(至少10个测试用例)与异常处理; 4. 撰写博客,记录 PSP 表格、设计实现、性能改进、测试展示与异常处理等内容。 |
第一次个人编程作业
GitHub 链接:https://github.com/yangyang254/yangyang/
一、PSP 表格
| PSP2.1 | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|
| Planning 计划 | ||
| · Estimate 估计任务时间 | 20 | 20 |
| Development 开发 | ||
| · Analysis 需求分析 | 30 | 20 |
| · Design Spec 设计文档 | 20 | 10 |
| · Coding Standard 代码规范 | 20 | 35 |
| · Design 具体设计 | 30 | 20 |
| · Coding 具体编码 | 120 | 160 |
| · Code Review 代码复审 | 30 | 50 |
| · Test 测试 | 40 | 40 |
| Reporting 报告 | ||
| · Test Report 测试报告 | 20 | 20 |
| · Size Measurement 计算工作量 | 10 | 5 |
| · Postmortem 事后总结 | 10 | 10 |
| 合计 | 约 350 分钟 | 约 390 分钟 |
二、计算模块接口的设计与实现
2.1 代码组织
项目 main.py 共包含 7 个函数和 2 个自定义异常类:
| 名称 | 类型 | 职责 |
|---|---|---|
FileReadError |
异常类 | 文件读取失败时抛出 |
FileWriteError |
异常类 | 文件写入失败时抛出 |
read_file |
函数 | 读取文件内容,处理编码/权限异常 |
write_result |
函数 | 将相似度写入答案文件,保留两位小数 |
preprocess |
函数 | 文本预处理:转小写、去标点 |
get_ngrams |
函数 | 生成字符级 n-gram 集合 |
calculate_similarity |
函数 | 计算 Jaccard 相似度 |
parse_args |
函数 | 解析命令行参数 |
main |
函数 | 主流程控制与异常分发 |
函数调用关系:
main()
├── parse_args() 解析命令行参数
├── read_file() 读取原文与抄袭版
├── calculate_similarity()
│ ├── preprocess() 文本预处理
│ └── get_ngrams() 生成 2-gram 集合
└── write_result() 输出结果
2.2 算法核心
本程序采用字符级 2-gram + Jaccard 相似度算法。
算法步骤:
-
预处理:将文本转小写,用正则
[^\w]去除所有标点符号、空格和特殊字符。 -
生成 2-gram:将预处理后的文本切分为相邻两个字符组成的片段集合。例如
"abcd"→{("a","b"), ("b","c"), ("c","d")}。 -
计算 Jaccard 相似度:
相似度 = |交集| / |并集|
独到之处:
- 短文本退化处理:当文本长度不足 2 时,退化为字符级集合比较,避免返回无意义的结果。
- 完全相等短路:若两个集合完全相同,直接返回 1.00,省去交集并集运算。
- 预编译正则:将
re.compile提到模块级别,避免每次调用重复编译。
2.3 关键流程图
读入原文 ──→ 预处理 ──→ 生成 2-gram 集合 ──┐
├──→ 计算交集/并集 ──→ 输出相似度
读入抄袭版 ─→ 预处理 ──→ 生成 2-gram 集合 ──┘
三、性能改进
3.1 性能分析
使用 cProfile 对 10 万字左右的文本进行性能分析:
python -m cProfile -s tottime 3124004185\main.py testfile\testfile0\big_orig.txt testfile\testfile0\big_copy.txt testfile\testfile0\ans.txt
优化前的性能分析结果截图:

ncalls tottime percall cumtime percall filename:lineno(function)
2 0.037 0.018 0.037 0.018 main.py:18(get_ngrams)
3 0.010 0.003 0.010 0.003 {built-in method _io.open}
2 0.004 0.002 0.004 0.002 {method 'sub' of 're.Pattern'}
1 0.002 0.002 0.046 0.046 main.py:28(calculate_similarity)
消耗最大的函数:get_ngrams,耗时 0.037s,占总时间的 63%。原因是原实现用 text[i:i+2] 字符串切片,为 10 万字的文本创建了约 10 万个临时字符串对象,内存分配和对象创建开销巨大。
3.2 优化手段
- 用
zip替代字符串切片:zip(text, text[1:])直接生成元组,减少临时字符串的创建。 - 预编译正则表达式:将
re.compile(r'[^\w]')提到模块级别,避免每次调用重复编译。 - 集合相等短路:在计算交集并集前先判断
set1 == set2,相同则直接返回 1.00。
优化后的核心代码:
CLEAN_PATTERN = re.compile(r'[^\w]')
def get_ngrams(text, n=2):
if len(text) < n:
return set()
return set(zip(*(text[i:] for i in range(n))))
3.3 优化结果
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 总耗时 | 0.059s | 0.060s |
| get_ngrams 耗时 | 0.037s | 0.043s |
| 处理能力 | 10万字/0.06s | 10万字/0.06s |
结论:在当前测试规模下,优化效果不明显。分析原因是测试文件规模较小,算法耗时占比不高,文件 IO 和正则替换占据了相当时间。但当前性能已远低于 5 秒限制,完全满足作业要求。若未来处理更大文件(如百万字级别),zip 方案的性能优势会更明显。
优化后的性能分析截图。

四、单元测试展示
4.1 测试思路
采用白盒测试方法,综合使用以下策略设计测试用例:
- 等价类划分:将输入分为"完全相同"、"完全不同"、"部分相似"、"空文本"、"单字符"等类别。
- 边界值分析:测试空字符串、长度为 1 的字符串、只有标点的字符串。
- 异常路径测试:测试文件不存在、参数数量错误、编码错误、无权限等场景。
- Mock 测试:对难以自然触发的异常(如
PermissionError、OSError)使用unittest.mock模拟。
4.2 测试用例清单(共 32 个)
| 测试类 | 测试函数 | 测试目的 |
|---|---|---|
| TestPreprocess | test_preprocess_lowercase | 验证大写转小写 |
| test_preprocess_remove_punctuation | 验证标点去除 | |
| test_preprocess_empty | 验证空字符串 | |
| TestGetNgrams | test_get_ngrams_normal | 验证正常 2-gram 生成 |
| test_get_ngrams_short_text | 验证短文本返回空集 | |
| test_get_ngrams_repeated | 验证重复字符去重 | |
| TestCalculateSimilarity | test_identical_text | 完全相同文本 → 1.00 |
| test_completely_different | 完全不同文本 → 接近 0 | |
| test_both_empty | 两个空文本 → 0.00 | |
| test_partial_similarity | 部分相似 → 0~1 之间 | |
| test_case_insensitive | 大小写不同但内容相同 → 1.00 | |
| TestFileIO | test_read_file_normal | 正常读取 |
| test_read_file_not_exist | 文件不存在 → FileReadError | |
| test_write_result_format | 输出保留两位小数 | |
| test_write_result_rounding | 正确四舍五入 | |
| TestCommandLineArgs | test_missing_args | 参数不足 → 非 0 退出码 |
| test_nonexistent_input_file | 输入文件不存在 → 非 0 退出码 | |
| TestExceptions | test_read_dir_as_file | 目录当文件读 → FileReadError |
| test_read_file_wrong_encoding | 非 UTF-8 → FileReadError | |
| test_write_to_invalid_dir | 目录不存在 → FileWriteError | |
| test_parse_args_too_few | 参数不足 → ValueError | |
| test_parse_args_too_many | 参数过多 → ValueError | |
| test_parse_args_normal | 正常参数解析 | |
| TestExceptionBranches | test_read_file_permission_error | 模拟无权限读取 |
| test_read_file_os_error | 模拟 IO 错误 | |
| test_write_file_permission_error | 模拟无权限写入 | |
| test_write_file_os_error | 模拟磁盘错误 | |
| test_main_success | main 正常流程 | |
| test_main_value_error | main 参数错误 → 退出码 1 | |
| test_main_read_error | main 读取错误 → 退出码 2 | |
| test_main_write_error | main 写入错误 → 退出码 3 | |
| test_main_unknown_error | main 未知异常 → 退出码 9 |
4.3 部分测试代码
class TestCalculateSimilarity(unittest.TestCase):
"""测试相似度计算函数"""
def test_identical_text(self):
"""完全相同的文本,相似度应为 1.00"""
text = "今天是星期天,天气晴。"
self.assertAlmostEqual(calculate_similarity(text, text), 1.00, places=2)
def test_both_empty(self):
"""两个空文本,相似度应为 0.00"""
self.assertEqual(calculate_similarity("", ""), 0.00)
def test_partial_similarity(self):
"""部分相似的文本,相似度应在 0 和 1 之间"""
sim = calculate_similarity("今天是星期天", "今天是周天")
self.assertGreater(sim, 0.0)
self.assertLess(sim, 1.0)
class TestExceptionBranches(unittest.TestCase):
"""用 mock 覆盖难以自然触发的异常分支"""
def test_read_file_permission_error(self):
"""读取文件无权限时应抛 FileReadError"""
with patch("builtins.open", side_effect=PermissionError("denied")):
with self.assertRaises(FileReadError):
read_file(self.path)
def test_main_unknown_error(self):
"""未知异常时 main() 应以退出码 9 退出"""
import main as m
argv = ["main.py", self.path, self.path, "ans.txt"]
with patch.object(sys, "argv", argv):
with patch.object(m, "calculate_similarity", side_effect=RuntimeError("boom")):
with self.assertRaises(SystemExit) as cm:
m.main()
self.assertEqual(cm.exception.code, 9)
4.4 测试覆盖率
运行以下命令查看覆盖率:
coverage run -m unittest test_main.py
coverage report
coverage html
覆盖率结果:
Name Stmts Miss Cover
----------------------------------
main.py 78 3 96%
test_main.py 171 3 98%
----------------------------------
TOTAL 249 6 98%
覆盖率与测试运行结果截图。

五、异常处理说明
5.1 异常设计目标
| 异常类型 | 触发场景 | 处理方式 | 退出码 |
|---|---|---|---|
ValueError |
命令行参数数量不对 | 打印用法提示 | 1 |
FileReadError |
文件不存在、不是文件、编码错误、无权限、IO错误 | 打印错误路径与原因 | 2 |
FileWriteError |
输出目录不存在、无权限写入、磁盘错误 | 打印错误路径与原因 | 3 |
| 其他异常 | 未预期的错误 | 打印错误信息 | 9 |
5.2 异常处理代码
def read_file(file_path):
if not os.path.exists(file_path):
raise FileReadError(f"文件不存在: {file_path}")
if not os.path.isfile(file_path):
raise FileReadError(f"路径不是文件: {file_path}")
try:
with open(file_path, 'r', encoding='utf-8') as f:
return f.read()
except UnicodeDecodeError as e:
raise FileReadError(f"文件编码错误,请使用 UTF-8 编码: {file_path}") from e
except PermissionError as e:
raise FileReadError(f"无权限读取文件: {file_path}") from e
except OSError as e:
raise FileReadError(f"读取文件失败: {file_path} ({e})") from e
5.3 异常测试样例
场景一:文件不存在
def test_read_file_not_exist(self):
"""读取不存在的文件应抛出 FileReadError"""
with self.assertRaises(FileReadError):
read_file(os.path.join(self.temp_dir, "not_exist.txt"))
运行结果:FileReadError: 文件不存在: C:\...\not_exist.txt
场景二:参数数量错误
def test_parse_args_too_few(self):
"""参数不足应抛 ValueError"""
with self.assertRaises(ValueError):
parse_args(["main.py", "a.txt"])
运行结果:ValueError: 参数数量错误。用法: python main.py <原文文件> <抄袭版文件> <答案文件>
场景三:输出目录不存在
def test_write_to_invalid_dir(self):
"""写入不存在的目录应抛 FileWriteError"""
bad_path = os.path.join(self.temp_dir, "not_exist_dir", "ans.txt")
with self.assertRaises(FileWriteError):
write_result(bad_path, 0.5)
运行结果:FileWriteError: 写入文件失败: ... ([Errno 2] No such file or directory)
场景四:未知异常(通过 mock 模拟)
def test_main_unknown_error(self):
"""未知异常时 main() 应以退出码 9 退出"""
with patch.object(m, "calculate_similarity", side_effect=RuntimeError("boom")):
with self.assertRaises(SystemExit) as cm:
m.main()
self.assertEqual(cm.exception.code, 9)
运行结果:[未知错误] boom,退出码 9。
六、总结与反思
通过本次作业,我完整地经历了一个软件项目的开发流程:从需求分析、算法设计、编码实现,到代码质量检查、性能分析、单元测试和异常处理。最大的收获有三点:
- 测试不是可有可无的:最初我觉得写测试浪费时间,但当我重构
read_file后,旧测试立刻发现了接口不兼容的问题,这让我认识到单元测试的价值。 - 性能分析要基于数据:凭感觉优化往往收效甚微,用
cProfile定位真正的瓶颈才靠谱。 - 异常处理是工程素养的体现:一个能优雅处理错误的程序,才是真正可用的程序。
浙公网安备 33010602011771号