个人项目

这个作业属于哪个课程 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 相似度算法。

算法步骤

  1. 预处理:将文本转小写,用正则 [^\w] 去除所有标点符号、空格和特殊字符。

  2. 生成 2-gram:将预处理后的文本切分为相邻两个字符组成的片段集合。例如 "abcd"{("a","b"), ("b","c"), ("c","d")}

  3. 计算 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

优化前的性能分析结果截图
735ee13e6aeafc0858326e638bae7ed9

   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 优化手段

  1. zip 替代字符串切片zip(text, text[1:]) 直接生成元组,减少临时字符串的创建。
  2. 预编译正则表达式:将 re.compile(r'[^\w]') 提到模块级别,避免每次调用重复编译。
  3. 集合相等短路:在计算交集并集前先判断 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 方案的性能优势会更明显。

优化后的性能分析截图
abde2d203c29b7ece92b3917e2b50128


四、单元测试展示

4.1 测试思路

采用白盒测试方法,综合使用以下策略设计测试用例:

  • 等价类划分:将输入分为"完全相同"、"完全不同"、"部分相似"、"空文本"、"单字符"等类别。
  • 边界值分析:测试空字符串、长度为 1 的字符串、只有标点的字符串。
  • 异常路径测试:测试文件不存在、参数数量错误、编码错误、无权限等场景。
  • Mock 测试:对难以自然触发的异常(如 PermissionErrorOSError)使用 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%

覆盖率与测试运行结果截图
f2b6549a34ed2fb7c6f14465a7160979


五、异常处理说明

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。


六、总结与反思

通过本次作业,我完整地经历了一个软件项目的开发流程:从需求分析、算法设计、编码实现,到代码质量检查、性能分析、单元测试和异常处理。最大的收获有三点:

  1. 测试不是可有可无的:最初我觉得写测试浪费时间,但当我重构 read_file 后,旧测试立刻发现了接口不兼容的问题,这让我认识到单元测试的价值。
  2. 性能分析要基于数据:凭感觉优化往往收效甚微,用 cProfile 定位真正的瓶颈才靠谱。
  3. 异常处理是工程素养的体现:一个能优雅处理错误的程序,才是真正可用的程序。

posted @ 2026-09-14 13:39  肖武阳  阅读(20)  评论(0)    收藏  举报