结对项目(刘俊宁、段旷卓)
软工作业:小学四则运算题目生成器(结对项目)
作业仓库:
(https://github.com/M1kk0zz0615/SoftwareEngineeringHW/tree/main/3124004479/SE3rdHW)
学号 3124004479,姓名 刘俊宁;学号 3124004466,姓名 段旷卓
课程 计科24级78班 - 软件工程
零、项目概览
一个自动生成小学四则运算题目的命令行程序,用 C# 实现,共 8 个源文件、约 2200 行代码。
# 生成 10 道数值范围 10 以内的题目,输出 Exercises.txt 与 Answers.txt
Myapp.exe -n 10 -r 10
# 判定答案对错,输出 Grade.txt
Myapp.exe -e Exercises.txt -a Answers.txt
程序实际生成的一道题与答案:
9 × (2 + 0) = 18
5 − 4/7 + 1/4 = 4'19/28
1'1/8 ÷ 9 ÷ (2 + 1) = 1/24
一、PSP 表格(预估)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) |
|---|---|---|
| Planning | 计划 | 30 |
| · Estimate | · 估计这个任务需要多少时间 | 30 |
| Development | 开发 | 360 |
| · Analysis | · 需求分析(包括学习新技术) | 40 |
| · Design Spec | · 生成设计文档 | 30 |
| · Design Review | · 设计复审(和同事审核设计文档) | 20 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 15 |
| · Design | · 具体设计 | 45 |
| · Coding | · 具体编码 | 180 |
| · Code Review | · 代码复审 | 30 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 90 |
| Reporting | 报告 | 75 |
| · Test Report | · 测试报告 | 30 |
| · Size Measurement | · 计算工作量 | 15 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 |
| 合计 | 555 |
二、设计实现过程
2.1 需求里真正难的地方
把需求读一遍,会发现有 5 条语句看似普通、实现起来却互相牵制:
- 不出现负数——
e1 − e2必须满足e1 ≥ e2; - 除法结果必须是真分数——
e1 ÷ e2必须满足0 < e1 < e2; - 运算符不超过 3 个;
- 题目不能重复——"重复"的定义是"能通过有限次交换
+和×左右表达式变成同一题"; - 要支持一万道题。
难点在于:第 1、2 条约束是自下而上传递的(子表达式一确定,它的值就固定了,父节点的运算符选择必须迁就它),
而第 4 条的"重复"不是字符串比较——23 + 45 和 45 + 23 是两道不同的字符串、却是同一道题。
2.2 类的划分
一共 8 个类,按"数据 → 算法 → 交互"三层组织:
┌─────────────────────────── 交互层 ───────────────────────────┐
│ Program 入口:设置控制台编码、分派运行模式 │
│ CliOptions 命令行解析与校验(-n / -r / -e / -a) │
└───────────────────────────┬──────────────────────────────────┘
│
┌─────────────────────────── 算法层 ───────────────────────────┐
│ ProblemGenerator 造题:随机建树、保证三条约束、查重、写文件│
│ Grader 判题:读文件、逐题求解、比对、写 Grade.txt│
│ ExpressionParser 递归下降解析器(判题时把题面还原成树) │
└───────────────────────────┬──────────────────────────────────┘
│
┌─────────────────────────── 数据层 ───────────────────────────┐
│ Fraction 有理数:约分、四则运算、比较、格式化/解析 │
│ Expression 表达式树:求值、括号渲染、查重规范形式 │
└─────────────────────────────────────────────────────────────┘
SelfTest ── 横跨各层,66 项断言的回归测试
这样划分的理由:
Fraction与Expression不依赖任何业务概念,只做数学与结构,所以判题时可以直接复用——
判题器把题面解析成Expression,取值就得到了正确答案,不需要重写一套求值逻辑。ProblemGenerator是唯一"知道题目约束"的类。约束的保证集中在一个函数MakeNode里,
将来若需求变化(比如允许负数),只改这一处。- 解析器
ExpressionParser单独成类,与"渲染"(Expression.ToInfix)互逆,
这就形成了一个可以互相验证的闭环:渲染出来的题面必须能被解析回同一个值,这一点后来真的抓出了 bug(见 4.4 节)。
2.3 关键流程:生成一道题
GenerateOne()
│
├─ 随机决定运算符个数 k ∈ {1,2,3} ← 需求 6
│
├─ BuildRandom(k):递归建树
│ ├─ k = 0 → 叶子:随机生成一个数值(自然数或分数)
│ └─ k > 0 → 把 k-1 个运算符分给左右子树
│ 递归建出左右子树,再调用 MakeNode 连接
│
├─ MakeNode(kind, left, right) ← 约束全在这里保证
│ ├─ 减法:若左值 < 右值 → 交换左右子树 (需求 1:无负数)
│ ├─ 除法:若 0 < 左值 < 右值 → 直接使用
│ │ 否则若 0 < 右值 < 左值 → 交换左右
│ │ 否则(左右相等或含 0)→ 改用加/减/乘,不浪费这次尝试
│ └─ 加、乘:直接构造
│
├─ 候选筛选:0/1 这类"没营养"的操作数超过 2 个 → 丢弃重来
│
├─ 构造 Problem(算出题面、答案、查重规范串三个字符串)
│
└─ 用规范串在 HashSet 里查重 → 重复则丢弃重来,否则收下 ← 需求 4
2.4 关键流程:判题
Grade(exerciseFile, answerFile)
│
├─ 读两个文件(先按 UTF-8 解码,失败再退回 GBK)
├─ 逐行配对:
│ ├─ 去掉题面末尾的 "=",用 ExpressionParser 解析成表达式树
│ ├─ 表达式树的 Value 就是正确答案(程序自己算,不信任答案文件)
│ ├─ 把答案文件的文本用 Fraction.TryParse 解析成数值
│ └─ 数值相等 → 对,否则 → 错
└─ 输出 "Correct: 5 (1, 3, 5, 7, 9)" / "Wrong: 5 (2, 4, 6, 8, 10)"
判题只比较数值而不是字符串,所以正确答案是 3/2 时,答案文件写成 6/4 或 1'1/2 都算对——
这更贴近"判定学生答案对错"的本意。
三、代码说明
3.1 分数:用 BigInteger 精确表示,构造时就把状态规范化
public Fraction(BigInteger numerator, BigInteger denominator)
{
if (denominator.IsZero)
throw new ArgumentException("分母不能为 0。", "denominator");
// 统一符号:负号只保留在分子上,保证分母恒正
if (denominator.Sign < 0)
{
numerator = -numerator;
denominator = -denominator;
}
// 约分
BigInteger gcd = BigInteger.GreatestCommonDivisor(BigInteger.Abs(numerator), denominator);
if (gcd > BigInteger.One)
{
numerator = numerator / gcd;
denominator = denominator / gcd;
}
_numerator = numerator;
_denominator = denominator;
}
思路:把"约分"和"符号统一"放进构造函数,于是这个类任何时刻都只存在一种规范状态。
好处是后续代码全部变简单:
- 相等判断退化成
_numerator == other._numerator && _denominator == other._denominator,不用先通分; - 比较大小用交叉相乘
a*d与c*b,整数运算,没有浮点误差; - 作业里
1/6 + 1/8 = 7/24是精确结果,用double会得到 0.29166666...,再转回分数就可能出错。
输出格式也在这个类里收口,严格按作业要求:整数原样输出,真分数输出 3/5,
假分数输出成带分数 2'3/8:
BigInteger whole = numerator / _denominator; // 整数部分
BigInteger rest = numerator % _denominator; // 真分数部分的分子
if (whole.IsZero) return sign + rest + "/" + _denominator; // 3/5
if (rest.IsZero) return sign + whole; // 5
return sign + whole + "'" + rest + "/" + _denominator; // 2'3/8
3.2 表达式:括号不占节点,渲染时才按优先级补
作业文法 e = n | e1 + e2 | e1 − e2 | e1 × e2 | e1 ÷ e2 | (e) 里的括号不需要真的存进树里——
存了反而会出现 ((1 + 2)) 这种多余括号。我们在渲染时按两条规则决定要不要补:
private string Render(int parentPrecedence, bool needsParenOnEqualPrecedence)
{
if (IsLeaf) return _value.ToString();
int myPrecedence = Precedence;
string text = _left.Render(myPrecedence, false)
+ " " + OperatorChar(_kind) + " "
+ _right.Render(myPrecedence, IsNonAssociative);
if (myPrecedence < parentPrecedence || (myPrecedence == parentPrecedence && needsParenOnEqualPrecedence))
return "(" + text + ")";
return text;
}
规则一:子表达式优先级低于父节点时必须加括号((1 + 2) × 3)。
规则二:同优先级时,只有处在减法或除法的右侧才必须加括号——因为这两个运算不满足结合律。
效果对照:
| 表达式树 | 输出的题面 | 说明 |
|---|---|---|
(1+2)×3 |
(1 + 2) × 3 |
规则一 |
1×(2+3) |
1 × (2 + 3) |
规则一 |
1÷(2÷3) |
1 ÷ (2 ÷ 3) |
规则二:除法右侧保留括号 |
(1÷2)÷3 |
1 ÷ 2 ÷ 3 |
规则二:左结合,左侧不加括号 |
1−2−3 |
1 − 2 − 3 |
同上 |
所以程序输出的括号是最少且必要的,既不丢含义也不啰嗦。
3.3 查重:把表达式规范化成"可比较的键"
这是需求里最容易做错的一条。作业明确说了:
1+2+3和3+2+1是不重复的,因为前者等价于(1+2)+3、后者等价于(3+2)+1,
它们之间不能通过有限次交换变成同一个题目。
这句话反过来给出了正确答案:只在每个节点内部允许交换,不改变树的形状。于是:
public string ToCanonical()
{
if (IsLeaf) return _value.ToString();
string left = _left.ToCanonical();
string right = _right.ToCanonical();
// 加法和乘法满足交换律,把两个子树按字典序排好,消除左右顺序的差异
if (_kind == NodeKind.Add || _kind == NodeKind.Mul)
{
if (string.CompareOrdinal(left, right) > 0) { /* 交换 left 与 right */ }
}
// 每个二元节点整体加一对括号,保证结构信息不丢失
return "(" + left + OperatorChar(_kind) + right + ")";
}
每个二元节点整体加括号,是为了让"结构"在字符串里保留下来;排序只发生在加法和乘法节点,
于是结构不同的树一定得到不同的键:
| 题目 | 规范化结果 | 是否重复 |
|---|---|---|
23 + 45 / 45 + 23 |
都是 (23+45) |
重复 ✔ |
6 × 8 / 8 × 6 |
都是 (6×8) |
重复 ✔ |
3 + (2 + 1) / 1 + 2 + 3 |
都是 ((1+2)+3) |
重复 ✔ |
1 + 2 + 3 / 3 + 2 + 1 |
((1+2)+3) / ((2+3)+1) |
不重复 ✔ |
1 − 2 / 2 − 1 |
(1−2) / (2−1) |
不重复 ✔ |
这五组对照与作业需求 7 的正例反例逐条吻合,也是自检里固定下来的断言。
3.4 约束保证:让"重试"变成"换一种做法"
生成过程中最容易踩的坑是"生成了不合法的题就整道重来"。这样做有两个坏处:慢,而且
在数值范围小的时候容易陷入反复失败。
我们的做法是就地修正:
private Expression MakeNode(NodeKind kind, Expression left, Expression right)
{
if (kind == NodeKind.Sub)
{
// 减法不满足交换律,交换左右子树后就是另一道合法题目,且结果不会为负
if (left.Value < right.Value) return Expression.Binary(NodeKind.Sub, right, left);
return Expression.Binary(NodeKind.Sub, left, right);
}
if (kind == NodeKind.Div)
{
// 需求 5:e1 ÷ e2 的结果是真分数 ⇔ 0 < e1 < e2
if (left.Value.IsPositive && left.Value < right.Value)
return Expression.Binary(NodeKind.Div, left, right);
if (right.Value.IsPositive && right.Value < left.Value)
return Expression.Binary(NodeKind.Div, right, left);
// 左右相等或含 0,除法凑不出真分数 → 改用加/减/乘,不浪费这次尝试
NodeKind fallback = (NodeKind)_random.Next((int)NodeKind.Add, (int)NodeKind.Mul + 1);
return MakeNode(fallback, left, right);
}
return Expression.Binary(kind, left, right);
}
为什么交换左右是合法的:− 和 ÷ 不满足交换律,交换后得到的是一道不同的题,
但它同样满足约束,所以没有"生成错了"这回事——只是换了一道题而已。
只有除法在"左右相等或含 0"时才真的无解,这时换成加/减/乘,一道题就稳稳地生成了。
3.5 判题:自己算,而不是对答案
public static bool TrySolve(string exerciseLine, out Fraction answer)
{
// 题面形如 "1 + 2 = ",先去掉等号及其后面的内容
int equalsIndex = exerciseLine.LastIndexOf('=');
string expressionText = equalsIndex >= 0 ? exerciseLine.Substring(0, equalsIndex) : exerciseLine;
Expression expression;
if (!ExpressionParser.TryParse(expressionText, out expression)) return false;
answer = expression.Value; // 复用表达式树的求值能力
return true;
}
判题器没有自己实现求值,而是复用生成时用的那套表达式树。这带来两个直接好处:
- 生成与判题对"什么是正确答案"的理解永远不会不一致;
- 判题器与生成器共享同一份经过 66 项断言验证的代码路径。
四、效能分析
4.1 测量方法
程序本身会打印耗时,但要做归因还不够。我们写了一个临时基准程序
(编译时排除 Program.cs,与源码一起编译),对每个阶段运行 5 次取最小值以压低噪声。
测的是 -n 10000 -r 10。
4.2 实测数据
【发布到博客园时】把
docs/profiling/perf-analysis.svg用浏览器打开、截图上传,替换掉上面这行即可。
若博客园支持 SVG 直传,也可以直接上传该文件。测量方法与原始数据见docs/profiling/README.md。
| 阶段 | 耗时 | 占比 / 单次成本 |
|---|---|---|
| 生成 10000 道题(端到端,含查重) | 68.9 ms | 6.89 μs / 题 |
| ├ 构造题面 / 答案 / 规范串 | 15.1 ms | 占生成 22% |
│ ├ ToInfix 题面渲染 |
8.8 ms | 0.88 μs / 次 |
│ └ ToCanonical 查重键 |
6.0 ms | 0.60 μs / 次 |
| ├ 查重:HashSet(本项目采用) | 1.6 ms | 0.16 μs / 题 |
| └ 查重:List 线性查找(朴素做法) | 589.9 ms | 慢 380 倍 |
| 写出 Exercises.txt + Answers.txt | 4.7 ms | 一次写盘 |
| 判题 10000 道(端到端) | 30.7 ms | 3.07 μs / 题 |
| └ 其中解析表达式并求值 | 18.0 ms | 占判题 59% |
4.3 瓶颈定位与改进措施
① 查重结构是唯一的"生死选择"(已实施)
上表里最刺眼的一行:同样是对 10000 个规范串去重,HashSet 只要 1.6 ms,
而用 List<string>.Contains 线性查找要 589.9 ms——是前者的 380 倍,
甚至超过了当前整个生成流程的 8 倍。原因是线性查找要做
n(n-1)/2 ≈ 5000 万次字符串比较,而哈希只需算一次哈希值再定位桶。
这就是我们一开始就选 HashSet<string> 的原因,也是这节最想强调的一点:
性能问题大部分在设计阶段就决定了,而不是靠事后调优。
② 查重键只算一次并复用(已实施)
Problem 在构造时把规范串算好存起来,查重的 Contains 与 Add 复用同一个字符串对象:
Problem problem = new Problem(expression); // 题面、答案、规范串在这里一次算好
if (_generated.Contains(problem.Canonical)) // 复用,不重算
{
continue;
}
_generated.Add(problem.Canonical);
如果写成 _generated.Contains(problem.Expr.ToCanonical()),10000 道题就要多花约 6 ms
(正好是 ToCanonical 那一行的量级)。
③ 子表达式的值在构造节点时算好(已实施)
Expression.Binary 在构造节点时就把 left.Value ⊕ right.Value 算出来存进 _value。
因为约束检查("左值是否小于右值")、答案计算、判题比对都要读这个值,
如果每次现算,一棵 3 个运算符的树要被反复递归求值好几遍。
④ 避免无谓的重试(已实施)
3.4 节的 MakeNode 就是为此设计的:减法用交换代替重试,除法凑不出真分数时改用其他运算符。
按概率估算,重试率因此降到 2%~3%(主要来自"题目太水"的过滤),
也就是说 10000 道题大约只多建了几百棵树。
⑤ 写文件用 StringBuilder 一次拼好再落盘(已实施)
10000 行如果每行调用一次文件写入,就是 10000 次系统调用;现在是先在内存里拼成
一个大字符串,再调用两次 File.WriteAllText。实测写两个文件只要 4.7 ms。
⑥ 考虑过、但主动放弃的优化
一个看起来不错的想法是:把查重判定提前到构造 Problem 之前——先算规范串、判重,
通过了再去构造题面字符串,这样被丢弃的候选就省下了题面渲染的开销。
但实测重复率低于 2%,能省下的大约是 8.8 ms × 2% ≈ 0.18 ms,
而代价是把"构造一道题"的逻辑拆成两处、可读性下降。收益远小于复杂度,因此放弃。
这条比前五条更值得记下来:优化的前提是测量,不是直觉。
4.4 附:测试发现的一个真实缺陷
性能之外,这次测试还抓出了一个正确性缺陷,值得一提,因为它非常隐蔽。
自检的规模测试会生成 10000 道题、再逐题"把题面重新解析一遍,看结果是否等于答案"。
第一次跑就报了 166 道不一致(1.66%),例如:
题面 [4/7 ÷ 6 ÷ 4/9 = ] 答案 [3/14] 重新解析得到 [1/378]
根因在解析器的 ParseNumber:读到整数部分 4 之后,没有撇号就直接 return 了整数,
把后面的 /9 留给了外层的除法运算符,于是 ÷ 4/9 被算成了 ÷ 4 ÷ 9。
它之所以难被发现,是因为 1/6 + 1/8 这类测试照样通过——整数当被除数时
1/6 与 1÷6 的数值完全相同,只有分数出现在除号右侧时才会出错。
修复方式就是补上那个缺失的判断(先判断下一个字符是不是 /,再决定返回整数还是分数):
// 没有撇号:可能是 "a/b" 形式的分数,也可能只是一个整数。
// 注意必须在这里判断 '/',否则 "6 ÷ 4/9" 会被误读成 "6 ÷ 4 ÷ 9"。
if (_pos < _text.Length && _text[_pos] == '/')
{
_pos++;
string denominatorText = ReadDigits();
if (denominatorText.Length == 0) throw new FormatException("分数缺少分母。");
return new Fraction(BigInteger.Parse(wholeText), BigInteger.Parse(denominatorText));
}
return new Fraction(BigInteger.Parse(wholeText));
教训:单元测试要覆盖"等价但写法不同"的情形。1/6 与 1÷6 数值相同,
但一个是"分数",一个是"除法运算",解析路径完全不同——只测其中一种,缺陷就会漏过去。
而"生成一万道题再全部回判"这种端到端的规模测试,正是为了让这种漏网的少数派暴露出来。
4.5 效能改进的时间开销
- 设计阶段选定哈希查重结构、缓存规范串与节点值:约 20 分钟(含在具体设计中)
- 编写基准程序并采集数据:约 25 分钟
- 分析数据、否决"提前判重"优化:约 15 分钟
- 合计约 60 分钟
最终结果:生成并写出 10000 道题 68.9 ms,判题 10000 道 30.7 ms,远快于需求。
五、代码规范
- 所有公共类型与成员都有 XML 文档注释,注释写"为什么"而不是"做了什么";
- 命名遵循 C# 约定:类与公共成员用 PascalCase,私有字段用
_camelCase; - 命令行参数、文件名、概率等常量全部用
const集中定义,不散落在代码里; - 异常处理有明确边界:底层抛具体异常(如分母为 0 抛
ArgumentException),
TryParse风格的方法用返回值表示失败,最外层Main兜底并给出友好信息; - 单文件不超过 400 行,职责单一;8 个源文件按"数据 → 算法 → 交互"分层,无循环依赖。
六、测试运行
程序内置了自检命令,66 项断言可一键复现:
Myapp.exe --selftest # 全部通过时退出码 0
6.1 部分测试用例(输入 → 预期输出)
| # | 测试点 | 输入 | 预期输出 | 实际 |
|---|---|---|---|---|
| 1 | 分数约分 | 6/8 |
3/4 |
✔ |
| 2 | 分数加法(作业原例) | 1/6 + 1/8 |
7/24 |
✔ |
| 3 | 分数减法 | 1/2 − 1/3 |
1/6 |
✔ |
| 4 | 假分数格式化为带分数 | 19/8 |
2'3/8 |
✔ |
| 5 | 带分数解析(含中文撇号) | 2'3/8、2’3/8 |
都是 19/8 |
✔ |
| 6 | 分母为 0 | new Fraction(1, 0) |
抛 ArgumentException |
✔ |
| 7 | 括号优先 | (1 + 2) × 3 |
9 |
✔ |
| 8 | 左结合 | 1 ÷ 3 × 3 |
1 |
✔ |
| 9 | 最少括号渲染 | 树 1÷(2÷3) |
1 ÷ (2 ÷ 3) |
✔ |
| 10 | 左结合不加括号 | 树 (1÷2)÷3 |
1 ÷ 2 ÷ 3 |
✔ |
| 11 | 查重:交换律 | 23 + 45 与 45 + 23 |
视为同一题 | ✔ |
| 12 | 查重:结构性重复 | 3 + (2 + 1) 与 1 + 2 + 3 |
视为同一题 | ✔ |
| 13 | 查重:不可交换 | 1 + 2 + 3 与 3 + 2 + 1 |
不是同一题 | ✔ |
| 14 | 残缺表达式容错 | 1 + 、(1 + 2 |
解析失败而不崩溃 | ✔ |
| 15 | 约束:无负数 | 300 道随机题 | 所有减法节点 左 ≥ 右 | ✔ |
| 16 | 约束:除法结果为真分数 | 300 道随机题 | 所有除法节点 0 < 左 < 右 |
✔ |
| 17 | 约束:运算符个数 | 300 道随机题 | 每道 ≤ 3 个 | ✔ |
| 18 | 约束:数值范围 | 300 道随机题 | 所有数值 < r | ✔ |
| 19 | 去重(规模) | -r 10 生成 500 道 |
规范串去重后仍是 500 个 | ✔ |
| 20 | 端到端(规模) | 生成 10000 道并回判 | 10000 道全对,0 处不一致 | ✔ |
| 21 | 判题统计 | 10 道人造题(错 5 题) | Correct: 5 (1, 3, 5, 7, 9) / Wrong: 5 (2, 4, 6, 8, 10) |
✔ |
| 22 | 判题容错 | 正确答案 1/2,答案文件写 3/6 |
判对 | ✔ |
| 23 | 极端范围 | -r 1 生成 100 道 |
不崩溃,生成已用尽并提示 | ✔ |
6.2 为什么我们可以确信程序是正确的
- 不变量检查,而不是只看输出。生成类的测试不是"结果看着对就行",而是遍历每一棵生成的树,
逐节点断言三条约束(无负数、除法为真分数、运算符数 ≤ 3)和数值范围。300 道题的
每一棵树、每个节点都检查过,一处异常都不允许存在。 - 正反例都有。查重的测试既包含"应该判为重复"的 4 组,也包含"不应该判为重复"的 3 组
(1+2+3vs3+2+1等)。只测正例很容易写出一个"把所有题都判成重复"的假实现。 - 端到端闭环。生成 10000 道题 → 写文件 → 读回来 → 判题 → 要求 10000 道全对。
这一步同时验证了生成、写文件、读文件、解析、求值、比对、报告 7 个环节。
4.4 节那个缺陷就是被这一步抓出来的。 - 人工可验证的样例。
samples/里有 10 道人工构造的题目与答案,
其中答案的正确性是手算核对过的(如2'3/8 × 2 = 19/4 = 4'3/4),
判题结果与作业示例给出的格式逐字符一致。 - 理论与实现对照。查重的规范形式、括号渲染规则、约束的保证方式,
都能在作业需求里找到逐条对应的依据(见 3.3、3.2、3.4 节的对照表),
不是"试出来的",而是"推出来的"。
七、PSP 表格(实际)
| PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
|---|---|---|---|
| Planning | 计划 | 30 | 25 |
| · Estimate | · 估计这个任务需要多少时间 | 30 | 25 |
| Development | 开发 | 360 | 350 |
| · Analysis | · 需求分析(包括学习新技术) | 40 | 35 |
| · Design Spec | · 生成设计文档 | 30 | 25 |
| · Design Review | · 设计复审(和同事审核设计文档) | 20 | 15 |
| · Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 15 | 10 |
| · Design | · 具体设计 | 45 | 40 |
| · Coding | · 具体编码 | 180 | 150 |
| · Code Review | · 代码复审 | 30 | 25 |
| · Test | · 测试(自我测试,修改代码,提交修改) | 90 | 110 |
| Reporting | 报告 | 75 | 95 |
| · Test Report | · 测试报告 | 30 | 35 |
| · Size Measurement | · 计算工作量 | 15 | 20 |
| · Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 30 | 40 |
| 合计 | 555 | 530 |
与预估的偏差分析:
- 测试超出预估 20 分钟(90 → 110)。主要是没预料到解析器那个隐蔽缺陷——定位它要理解
"为什么1/6 + 1/8能通过而4/7 ÷ 6 ÷ 4/9不能",花的调试时间比写测试本身还多。 - 报告超出预估 20 分钟(75 → 95)。效能分析要写基准程序、采集数据、画图,
这部分在预估时被低估了。 - 编码比预估少 30 分钟(180 → 150)。因为设计阶段把"约束如何保证"想清楚了
(MakeNode用交换代替重试),编码时几乎没有返工。 - 合计 530 分钟,比预估的 555 分钟少 4.5%,整体估算偏保守。
八、项目小结
8.1 成败得失
做对的三件事:
- 把约束的保证收敛到一个函数。
MakeNode是唯一处理"不出现负数"和"除法结果为真分数"的地方,
需求一变只改这一处。如果让每个运算符各自处理约束,几乎一定会出现"某个分支漏了一种情况"。 - 分层复用。判题器不重新实现求值,而是复用生成器那套表达式树,
于是"生成"和"判题"对正确答案的理解在结构上就不可能不一致。 - 不变量检查 + 端到端闭环。自检不只比对输出,而是逐节点断言三条题目约束;
再加上"生成一万道题再全部回判",后者抓出了 1.66% 的隐蔽缺陷。
做错的两件事:
- 需求里自相矛盾的措辞没有第一时间拿出来对质。"真分数"这个词在需求里出现在两处:
一处把1'1/2也列为真分数(等于承认带分数),另一处又要求"除法的结果应是真分数"。
按前一种读法除法几乎不需要约束,按后一种读法结果必须严格小于 1。
我们各自按自己的理解先写了一阵才对齐。这类歧义应该当场摊开来定,而不是先写再说。 - 验收标准只有"满足需求条文",没有"输出像不像小学题"。需求列的全是硬约束
(数值范围、运算符个数、不重复、不为负),在这之外"生成的题目好不好"没有任何依据,
我们是在看到实际输出之后才回头补上过滤的。
这两件错事的共性是同一个:少问了一句。
8.2 结对感受
我们怎么分工
这次没有按模块切,而是按角色切:
- 刘俊宁负责构思与审阅:把需求里含糊的地方定死("真分数"到底含不含带分数、
"重复"到底怎么算)、定下数据结构与关键约束的实现思路、每次提交前对照需求逐条复查; - 段旷卓负责代码实现:把思路落成 C# 代码、搭内置自检、写基准程序量性能、
整理 README 与提交历史。
这个分法的好处是"想"和"做"互相咬合:我定下的每一条约束,都要在他那边能跑出可验证的结果;
他实现中一旦发现"这条约束不好落地",就退回我这边重新想。代价是沟通成本——
下面三次分歧基本都出在这个咬合处。
印象最深的三次分歧
第一次是括号要不要存进语法树。我构思时定的方向是:括号不占节点,渲染时按优先级算出来。
理由是需求文法里的 (e) 只是说"可以给任意子表达式加括号",如果把括号存成节点,
反而要在渲染时再反过来判断"哪些括号是多余的"。段旷卓实现到渲染这一步时提出了一个我没想到的角度:
既然括号是渲染时算出来的,那么渲染出来的题面必须能被解析回同一个值,否则判题就会出错。
这句话被我收进了设计约束,后来变成了自检里的断言——1 ÷ (2 ÷ 3) 必须保留括号、
(1 ÷ 2) ÷ 3 必须不加括号。我的体会是:我定的是结构,他补上的是这个结构的"验收条件";
一个构思只有能被验证,才算真的定下来了。
第二次是"1 + 2 + 3 和 3 + 2 + 1 到底算不算同一道题"。我构思查重方案时,
最初把注意力放在"加法和乘法可以交换"上,觉得这两道题不过是换了个顺序。
是段旷卓在实现时把需求原文的推理链抄出来对到代码上——1 + 2 + 3 等价于 (1+2)+3、
3 + 2 + 1 等价于 (3+2)+1,交换只能在每个节点内部做,不能改变树的形状。
定下来之后,我把这个定义翻译成测试里的正例和反例:4 组"应该判为重复"、3 组"不应该判为重复"。
这件事让我记住:需求里含糊的一句话,往往藏着实现方式的全部答案;
而负责构思的那个人最该做的,就是把这种句子逐字读,而不是凭直觉定。
第三次是要不要专门为一万道题写端到端测试。功能做完、66 项断言全绿的时候,
我作为审阅方认为可以收工了——当时的判断是"三条约束都验证过了"。
段旷卓坚持再加一项:生成一万道题,再逐题把题面重新解析一遍、和答案逐个比对。
正是这一步抓出了 1.66% 的判题失误:解析器读到整数就提前返回,把 ÷ 4/9 读成了 ÷ 4 ÷ 9。
这个缺陷最阴险的地方在于,1/6 + 1/8 这类测试照样通过——整数当被除数时,
1/6 和 1÷6 的数值完全相同,只有分数出现在除号右侧时才会露馅。
如果按我的判断收工,这个缺陷会一直带到最终提交。这件事对我这个"负责审阅"的人尤其直接:
我复查的是"约束有没有被满足",他测的是"程序整体对不对"——
前者只能证明我没想漏,后者才能证明实现没写错。
段旷卓身上的闪光点
- 实现时的"验收意识"。他不只是把代码写出来,还会顺手把"这条思路怎么证明是对的"补上。
上面第一次分歧就是例子:我定了"括号不渲染进树里",他补的是"那渲染和解析必须互逆",
于是多出来一条能自动跑的断言。 - 工程判断力:先量再改,并且愿意记录取舍。性能那一节本来报一个
"一万道题 68.9 毫秒"就能交差,他坚持要分段计时做归因,结果量出来查重结构是唯一的生死选择:
HashSet只要 1.55 ms,而同样的去重换成List线性查找要 589.9 ms,慢 380 倍。
有了这个数,"为什么用哈希表"就从一句口头约定变成了有证据的设计决策。
更难得的是,他提议把"被我们否掉的优化"(提前判重只能省 0.18 ms,却要把逻辑拆成两处)
也写进文档——记录取舍才叫分析,只报数字那叫跑分。 - 对交付物完整性的执念。README 要能当运行说明用、目录结构要跟前两次作业对齐、
编译好的 exe 要一起进仓库——这些都不是需求里写着的条目,
但老师拿到手能不能直接跑起来,靠的恰恰是这些没人要求的事。
这次结对暴露出来、我需要改的地方
- 我审阅的覆盖面有盲区。我复查的是"每条约束有没有被满足",漏掉了"输入的写法有没有被覆盖"。
66 项断言全绿的时候我心里很踏实,事实是它们漏掉了一整条输入路径——分数作为除数。
教训是:用例要按输入的结构分类(分数在除号左侧、右侧、括号内、作为被减数……),
而不是按"我想到了什么"来写。这是我这次最大的收获。 - 构思时没有把规模带进来。设计方案的时候就应该先问一句"一万道题意味着什么"——
10000 个元素去重该用什么结构?这个问题在设计阶段问出来,比实现完再测量便宜得多。 - 对"做完了没有"的判断太依赖需求条文。第一版生成出来的题里出现了
1 × 1 × 1 × 2
这种没营养的内容,需求里没有任何一条禁止它,我是看到实际输出才意识到别扭的。
需求的边界之外还有"用户一眼看上去的感受",这部分只能靠看实际产物,不能靠读条文。
如果重来一次
我们会在动手之前先做三件事:把需求里含糊和自相矛盾的措辞列成一张清单逐条定死、
按输入的结构而不是按脑子里的灵感来设计测试用例、
在设计阶段就把"一万道题"这个规模代入结构选型。这几次返工加起来占了不少时间,
而它们的共同点是——都不需要多写一行代码,只需要在动手之前多花十分钟把话说清楚。

浙公网安备 33010602011771号