結(jié)對(duì)項(xiàng)目-生成四則運(yùn)算
| 這個(gè)作業(yè)屬于哪個(gè)課程 | https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience/ |
|---|---|
| 這個(gè)作業(yè)要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class34Grade23ComputerScience/homework/13479 |
| 姓名 | 學(xué)號(hào) | GitHub 項(xiàng)目地址 |
|---|---|---|
| 曾子軒 | 3123004763 | https://github.com/paulzzx/project2_AG/tree/main/project2 |
| 劉瑞康 | 3123004753 | https://github.com/paulzzx/project2_AG/tree/main/project2 |
一、PSP表
| PSP2.1 階段 | Personal Software Process Stages | 預(yù)估耗時(shí)(分鐘) | 實(shí)際耗時(shí)(分鐘) |
|---|---|---|---|
| Planning | 計(jì)劃 | 30 | 40 |
| Estimate | 估計(jì)這個(gè)任務(wù)需要多少時(shí)間 | 10 | 20 |
| Development | 開發(fā) | 240 | 360 |
| Analysis | 需求分析(包括學(xué)習(xí)新技術(shù)) | 30 | 30 |
| Design Spec | 生成設(shè)計(jì)文檔 | 30 | 40 |
| Design Review | 設(shè)計(jì)復(fù)審(和同事審核設(shè)計(jì)文檔) | 30 | 20 |
| Coding Standard | 代碼規(guī)范(為目前的開發(fā)制定合適的規(guī)范) | 20 | 10 |
| Design | 具體設(shè)計(jì) | 60 | 90 |
| Coding | 具體編碼 | 30 | 40 |
| Code Review | 代碼復(fù)審 | 20 | 30 |
| Test | 測(cè)試(自我測(cè)試,修改代碼,提交修改) | 20 | 40 |
| Reporting | 報(bào)告 | 40 | 60 |
| Test Report | 測(cè)試報(bào)告 | 20 | 40 |
| Size Measurement | 計(jì)算工作量 | 30 | 40 |
| Postmortem & Process Improvement Plan | 事后總結(jié),并提出過程改進(jìn)計(jì)劃 | 20 | 30 |
| 合計(jì) | 350 | 570 |
二、系統(tǒng)設(shè)計(jì)與實(shí)現(xiàn)
1.代碼組織
ArithmeticGenerator/
|——src/
│ |—— rational.py # 數(shù)字處理,假分?jǐn)?shù)轉(zhuǎn)換為帶分?jǐn)?shù)/字符串和數(shù)字的相互轉(zhuǎn)換
│ |—— expr_ast.py # 用AST表示表達(dá)式,節(jié)點(diǎn)為運(yùn)算符,葉子為數(shù)字。包括表達(dá)式的計(jì)算和打印
│ |—— canon.py # 遞歸處理表達(dá)式,返回規(guī)范化結(jié)果用于查重
│ |—— generator.py # 隨機(jī)生成表達(dá)式、校驗(yàn)與批量產(chǎn)出
│ |—— checker.py # 將字符串轉(zhuǎn)化為表達(dá)式計(jì)算,與答案比對(duì),輸出正確和錯(cuò)誤的題數(shù)/題號(hào)
│ |—— cli.py # 命令行接口:參數(shù)解析與主流程
│ |—— __ init __.py
|——tests/ # pytest 單元與集成測(cè)試
│ |—— test_rational.py
│ |—— test_expr_ast.py
│ |—— test_generator.py
│ |—— ...
2.關(guān)鍵函數(shù)調(diào)用
—— 生成過程 ——
run ? # 解析命令行參數(shù)
-> generate_to_files(n: int, r: int, max_ops: int = 3)? # 生成表達(dá)式和答案并寫入文件
-> generate_exercises(n: int, R: int, max_ops: int = 3) -> Tuple[List[str], List[str]]? # 批量生成表達(dá)式
-> random_expr(R: int, max_ops: int)? # 生成隨機(jī)表達(dá)式
-> canon_key(e: Expr)? # 規(guī)范化表達(dá)式,返回key用于查重
-> pretty(e: Expr) -> str? # 將查重后表達(dá)式轉(zhuǎn)換為字符串
-> answer_of(e: Expr) -> str? # 計(jì)算表達(dá)式結(jié)果并轉(zhuǎn)換為字符串
—— 批改/判分過程(單題)——
run ? # 解析命令行參數(shù)
->check_to_grade(exercises_path: str, user_answers_path: str)? # 檢查并把結(jié)果寫入grade
-> check_answers(exercises_path: str, user_answers_path: str, grade_out: str) -> Tuple[int, int, List[int]]? # 讀取題目計(jì)算并和答案比對(duì)
->_eval_from_text(expr_str: str) -> Fraction / parse_value(s: str) -> Fraction? #計(jì)算題目得到答案 /將答案字符串轉(zhuǎn)化為數(shù)值進(jìn)行比對(duì)
3.代碼說明
rational.parse_value
先匹配帶分?jǐn)?shù)、再匹配普通分?jǐn)?shù),避免歧義
解析失敗統(tǒng)一 ValueError
Fraction保證分?jǐn)?shù)精度,正則化確保格式
_INT_RE = re.compile(r"^\d+$")
_FRAC_RE = re.compile(r"^(\d+)/(\d+)$")
_MIXED_RE = re.compile(r"^(\d+)'(\d+)/(\d+)$")
def parse_value(s: str) -> Fraction:
if s is None:
raise ValueError("empty")
s = s.strip().replace("’", "'")
# 整數(shù)
if _INT_RE.fullmatch(s):
return Fraction(int(s), 1)
# 帶分?jǐn)?shù) a'b/c
m = _MIXED_RE.fullmatch(s)
if m:
a, b, c = (int(m.group(1)), int(m.group(2)), int(m.group(3)))
if c == 0:
print('分母為0')
raise ValueError("denominator is zero")
if b >= c:
# 帶分?jǐn)?shù)部分必須 0 <= b < c
print('帶分?jǐn)?shù)部分必須 0 <= b < c')
raise ValueError("improper mixed fraction")
return Fraction(a * c + b, c)
# 真/假分?jǐn)?shù) p/q
m = _FRAC_RE.fullmatch(s)
if m:
p, q = int(m.group(1)), int(m.group(2))
if q == 0:
print('分母為0')
raise ValueError("denominator is zero")
return Fraction(p, q)
raise ValueError(f"invalid value: {s!r}")
expr_ast.pretty
遞歸地遍歷表達(dá)式樹,將算術(shù)表達(dá)式格式化成字符串
如果節(jié)點(diǎn)是數(shù)字(Num),直接格式化輸出
如果是二元運(yùn)算(BinOp),遞歸打印左右子樹,并根據(jù)運(yùn)算符優(yōu)先級(jí)決定是否加括號(hào),最后用相應(yīng)符號(hào)(+ - × ÷)連接左右表達(dá)式,生成完整的算式字符串
def pretty(e: Expr) -> str:
if isinstance(e, Num):
return format_value(e.value)
assert isinstance(e, BinOp)
l_txt = pretty(e.left)
r_txt = pretty(e.right)
if _need_paren(e.op, e.left, is_right_child=False):
l_txt = f"({l_txt})"
if _need_paren(e.op, e.right, is_right_child=True):
r_txt = f"({r_txt})"
op_txt = {Op.ADD: "+", Op.SUB: "-", Op.MUL: "×", Op.DIV: "÷"}[e.op]
return f"{l_txt} {op_txt} {r_txt}"
canon.canon_key
等價(jià)只允許在 +/× 當(dāng)前結(jié)點(diǎn)交換左右子樹
去重確定性強(qiáng),配合生成器防止重復(fù)題目
def canon_key(e: Expr) -> Key:
"""
構(gòu)建表達(dá)式規(guī)范key,使等價(jià)表達(dá)式變成同一鍵。
"""
# 葉子:固定成最簡(jiǎn)分?jǐn)?shù)的 (num, den)
if isinstance(e, Num):
fr = Fraction(e.value) # 規(guī)整(含 0 -> 0/1)
return ('N', fr.numerator, fr.denominator)
# 遞歸到子樹,構(gòu)子鍵
assert isinstance(e, BinOp)
kl = canon_key(e.left)
kr = canon_key(e.right)
# 僅對(duì) ADD/MUL 在“本結(jié)點(diǎn)”做交換(排序);SUB/DIV 保持順序
if e.op in (Op.ADD, Op.MUL):
a, b = (kl, kr) if kl <= kr else (kr, kl)
return ('B', e.op.name, (a, b))
else: # Op.SUB / Op.DIV
return ('B', e.op.name, (kl, kr))
generator.generate_exercises
去重:canon_key + set,只允許“本結(jié)點(diǎn)交換”等價(jià)
非負(fù)約束:SUB 用“換位減”避免注入多余 + 節(jié)點(diǎn),保證 ≤ max_ops
非整數(shù)除法:DIV 分母為 0 就修復(fù);若整除則擾動(dòng)分母,確保結(jié)果分?jǐn)?shù)
輸出題面時(shí)用 leaf_formatter=format_value → 假分?jǐn)?shù)葉子顯示為帶分?jǐn)?shù),可讀性更強(qiáng)
def generate_exercises(n: int, R: int, max_ops: int = 3) -> Tuple[List[str], List[str]]:
"""生成恰好 n 道題(1≤n≤10000),操作數(shù)范圍 [0, R](R≥1)。
約束:
- 任意子表達(dá)式與最終結(jié)果非負(fù)
- 出現(xiàn) DIV 時(shí),該子表達(dá)式結(jié)果為分?jǐn)?shù)
- 運(yùn)算符數(shù) ≤ max_ops
- 去重:基于 canon_key(僅局部交換)
返回:(exercises, answers)
exercises[i] = f"{i+1}. <expr_str> ="
answers[i] = f"{i+1}. <value_str>"
不能達(dá)成 n 條 → 拋 AGGenerateError。
"""
# 參數(shù)校驗(yàn)
if not (1 <= n <= 10000):
print('n must be in [1, 10000]')
raise ValueError("n must be in [1, 10000]")
if R < 1:
print('R must be >= 1')
raise ValueError("R must be >= 1")
if max_ops < 1:
print('max_ops must be >= 1')
raise ValueError("max_ops must be >= 1")
seen = set()
exercises: List[str] = []
answers: List[str] = []
idx = 1
# 防死循環(huán)的硬上限
HARD_LIMIT = max(1000, 20 * n)
attempts = 0
while len(exercises) < n:
if attempts >= HARD_LIMIT:
print('cannot reach the required number of unique exercises')
raise AGGenerateError("cannot reach the required number of unique exercises")
attempts += 1
# 生成隨機(jī)算式
try:
e = random_expr(R, max_ops)
except AGGenerateError:
print('random generator failed')
raise
#驗(yàn)證合法性
if not is_valid_expr(e):
continue
#驗(yàn)證唯一性
k = canon_key(e)
if k in seen:
continue
seen.add(k)
expr_str = pretty(e) # 題面:最小括號(hào)
val_str = answer_of(e) # 答案:凍結(jié)格式
exercises.append(f"{idx}. {expr_str} =")
answers.append( f"{idx}. {val_str}")
idx += 1
return exercises, answers
checker.check_answers
容錯(cuò)批改:用戶答案 ValueError(空值、分母缺失、分母為 0、亂寫)都判錯(cuò),流程繼續(xù)并生成成績(jī)
成績(jī)文件固定兩行、ID 升序,便于對(duì)比與自動(dòng)化檢查
def check_answers(exercises_path: str, user_answers_path: str, grade_out: str) -> Tuple[int, int, List[int]]:
"""
嚴(yán)格按題號(hào)對(duì)齊批改:
- 讀題面/答案(依賴 io_utils 的強(qiáng)格式)
- 題面經(jīng) _eval_from_text 得到標(biāo)準(zhǔn)答案(Fraction)
- 用戶答案用 rational.parse_value 解析(支持 '’')
- 比較 Fraction 是否相等
- 輸出成績(jī)文件兩行固定格式,并返回 (correct_count, wrong_count, wrong_ids)
"""
# 1) 讀取并做題號(hào)集合校驗(yàn)(格式不對(duì)由 io_utils 直接拋 ValueError)
ex_pairs = io_utils.read_exercises(exercises_path) # [(id, "<expr>"), ...]
an_pairs = io_utils.read_answers(user_answers_path) # [(id, "<value>"), ...]
ex_map: Dict[int, str] = dict(ex_pairs)
an_map: Dict[int, str] = dict(an_pairs)
ex_ids = set(ex_map.keys())
an_ids = set(an_map.keys())
if ex_ids != an_ids:
print('題號(hào)不匹配')
raise ValueError("mismatched ids between exercises and answers")
# 2) 批改(嚴(yán)格按升序題號(hào)對(duì)齊)
correct_ids: List[int] = []
wrong_ids: List[int] = []
for i in sorted(ex_ids):
gold = _eval_from_text(ex_map[i]) # 標(biāo)準(zhǔn)答案 Fraction
try:
pred = parse_value(an_map[i]) # 用戶答案 -> Fraction
except ValueError as e:
# 只特判“分母為 0”,判錯(cuò)但不中斷
if "denominator is zero" in str(e):
wrong_ids.append(i)
continue
# 其它解析錯(cuò)誤仍視為致命(保持原有行為/退出碼 4)
except ValueError:
# 任何“結(jié)構(gòu)不正確/解析失敗”的答案都當(dāng)作答錯(cuò),但不中斷
wrong_ids.append(i)
continue
raise
if gold == pred:
correct_ids.append(i)
else:
wrong_ids.append(i)
# 3) 寫成績(jī)文件(兩行固定格式;ID 升序)
def _fmt(ids: List[int]) -> str:
return "(" + ", ".join(map(str, ids)) + ")" if ids else "()"
correct_ids.sort()
wrong_ids.sort()
with open(grade_out, "w", encoding="utf-8") as f:
f.write(f"Correct: {len(correct_ids)} {_fmt(correct_ids)}\n")
f.write(f"Wrong: {len(wrong_ids)} {_fmt(wrong_ids)}\n")
return len(correct_ids), len(wrong_ids), wrong_ids
三、運(yùn)行測(cè)試
test_rational.py
@pytest.mark.parametrize(
"fr, expect_a, expect_rem",
[
(F(0, 1), 0, F(0, 1)), # 0 -> (0, 0/1)
(F(1, 2), 0, F(1, 2)), # 真分?jǐn)?shù) -> (0, 本身)
(F(7, 3), 2, F(1, 3)), # 假分?jǐn)?shù) -> 帶分?jǐn)?shù)
(F(5, 1), 5, F(0, 1)), # 整數(shù) -> (a, 0/1)
],
)
def test_to_mixed_basic(fr, expect_a, expect_rem):
a, rem = to_mixed(fr)
assert a == expect_a
assert rem == expect_rem
def test_to_mixed_invariants():
"""不變量:返回 (a, b/c),且 0 <= b < c,c>0;整數(shù)時(shí)余數(shù)固定為 0/1。"""
for fr in [F(0, 1), F(1, 2), F(7, 3), F(99, 10), F(5, 1)]:
a, rem = to_mixed(fr)
assert rem.denominator > 0
# 允許 0/1
if rem == 0:
assert rem == F(0, 1)
else:
assert 0 <= rem.numerator < rem.denominator
@pytest.mark.parametrize(
"fr, expect",
[
(F(0, 1), "0"), # 整數(shù) 0
(F(5, 1), "5"), # 整數(shù) k
(F(1, 2), "1/2"), # 0<fr<1 -> p/q(最簡(jiǎn))
(F(2, 4), "1/2"), # 自動(dòng)最簡(jiǎn)
(F(7, 3), "2'1/3"), # fr>1 -> a'b/c(ASCII 單引號(hào))
(F(6, 3), "2"), # 可整除 -> 整數(shù)
],
)
def test_format_value_freeze_rules(fr, expect):
assert format_value(fr) == expect
def test_format_value_uses_ascii_quote():
"""格式固定用 ASCII 單引號(hào)。即便解析接受彎引號(hào),format 仍輸出 ' 。"""
fr = parse_value("2’1/3") # 注意是彎引號(hào)
s = format_value(fr)
assert s == "2'1/3"
@pytest.mark.parametrize(
"s, expect",
[
("0", F(0, 1)),
("5", F(5, 1)),
(" 1/2 ", F(1, 2)), # 前后空白
("2'1/3", F(7, 3)), # 直引號(hào)
("2’1/3", F(7, 3)), # 彎引號(hào)
],
)
def test_parse_value_ok(s, expect):
assert parse_value(s) == expect
@pytest.mark.parametrize(
"s",
[
"1//2", # 語(yǔ)法錯(cuò)誤
"abc", # 非法
"1/0", # 分母為 0
"2''1/3", # 引號(hào)重復(fù)
"2'1/0", # 帶分?jǐn)?shù)里的分母為 0
],
)
def test_parse_value_invalid_raises(s):
with pytest.raises(ValueError):
parse_value(s)
@pytest.mark.parametrize(
"fr",
[
F(0, 1),
F(5, 1),
F(1, 2),
F(2, 3),
F(10, 4), # 2'1/2
F(999, 1000),
],
)
def test_format_parse_roundtrip(fr):
s = format_value(fr)
back = parse_value(s)
assert back == fr
test_expr_ast.py
def test_eval_basic_add_sub_mul_div():
assert eval_expr(add(n(1), n(2))) == F(3, 1)
assert eval_expr(sub(n(5), n(2))) == F(3, 1)
assert eval_expr(mul(n(3), n(4))) == F(12, 1)
assert eval_expr(div(n(8), n(4))) == F(2, 1)
def test_eval_with_fractions():
# 1/2 + 1/3 = 5/6
e = add(f(1,2), f(1,3))
assert eval_expr(e) == F(5, 6)
# (2/3) / (4/5) = (2/3) * (5/4) = 10/12 = 5/6
e = div(f(2,3), f(4,5))
assert eval_expr(e) == F(5, 6)
def test_eval_nested_precedence():
# (1 + 2) * 3 = 9
e = mul(add(n(1), n(2)), n(3))
assert eval_expr(e) == F(9, 1)
# 1 + 2 * 3 = 7
e = add(n(1), mul(n(2), n(3)))
assert eval_expr(e) == F(7, 1)
def test_eval_divide_by_zero_raises():
# 生成階段會(huì)避免,但 eval 遇到非法 AST 應(yīng)明確拋錯(cuò)
with pytest.raises(ZeroDivisionError):
eval_expr(div(n(1), n(0)))
def test_pretty_precedence_min_paren():
# (1 + 2) * 3 -> "(1 + 2) * 3" (低優(yōu)先級(jí)子,需括號(hào))
e = mul(add(n(1), n(2)), n(3))
assert pretty(e) == "(1 + 2) × 3"
# 1 + 2 * 3 -> "1 + 2 * 3"(高優(yōu)先級(jí)子,無(wú)需括號(hào))
e = add(n(1), mul(n(2), n(3)))
assert pretty(e) == "1 + 2 × 3"
def test_pretty_subtraction_rules_no_negative_semantics():
# 9 - (2 + 3) 必須括號(hào);9 >= 5 合法
e = sub(n(9), add(n(2), n(3)))
assert pretty(e) == "9 - (2 + 3)"
# 7 - 2 * 3 -> "7 - 2 * 3"(乘法優(yōu)先級(jí)高,無(wú)需括號(hào);7 >= 6 合法)
e = sub(n(7), mul(n(2), n(3)))
assert pretty(e) == "7 - 2 × 3"
# (5 - 2) - 1 -> "5 - 2 - 1"(左結(jié)合可省;5>=2、3>=1 均合法)
e = sub(sub(n(5), n(2)), n(1))
assert pretty(e) == "5 - 2 - 1"
# 5 - (7 - 3) -> 右子為 SUB 必須加括號(hào);5 >= 4 合法
e = sub(n(5), sub(n(7), n(3)))
assert pretty(e) == "5 - (7 - 3)"
def test_pretty_division_rules_noninteger_every_div():
# (1 / 2) * 3 -> "1 / 2 * 3"(左子為 DIV 可省;1/2 非整數(shù))
e = mul(div(n(1), n(2)), n(3))
assert pretty(e) == "1 ÷ 2 × 3"
# 1 * (2 / 3) -> "1 * (2 / 3)"(右子為 DIV 必須加)
e = mul(n(1), div(n(2), n(3)))
assert pretty(e) == "1 × (2 ÷ 3)"
# (1 / 2) / 3 -> "1 / 2 / 3"(左結(jié)合 DIV 可省;兩次 DIV 均非整數(shù))
e = div(div(n(1), n(2)), n(3))
assert pretty(e) == "1 ÷ 2 ÷ 3"
# 1 / (2 / 3) -> "1 / (2 / 3)"(右子 DIV 必須加;兩次 DIV 非整數(shù))
e = div(n(1), div(n(2), n(3)))
assert pretty(e) == "1 ÷ (2 ÷ 3)"
def test_pretty_fraction_leaf_format():
# 葉子分?jǐn)?shù)統(tǒng)一 p/q,不做帶分?jǐn)?shù)
e = add(f(1,2), n(1))
assert pretty(e) == "1/2 + 1"
其他測(cè)試不一一列出
為什么能確定程序正確: pytest 測(cè)試集合不僅驗(yàn)證了正常路徑,還覆蓋了邊界、異常、性質(zhì)(交換/結(jié)合/約束)和端到端行為(退出碼/I-O 格式),能有效防回歸,保證讀取-生成/批改-輸出這條鏈條穩(wěn)。
四、性能分析與改進(jìn)


改進(jìn)思路
- 合并“合法性檢查 + 求值”
- 問題:
is_valid_expr在檢查 SUB/DIV 時(shí)多次遞歸求值,eval_expr被反復(fù)調(diào)用。 - 改進(jìn):改為一次自底向上的檢查函數(shù),同時(shí)返回“是否合法 + 子樹值”,避免重復(fù)遍歷與重復(fù)求值。
- 延遲渲染 & 復(fù)用已算出的值
- 問題:候選表達(dá)式在“還未確認(rèn)保留”時(shí)就調(diào)用
pretty;答案生成再做一次求值。 - 改進(jìn):先合法性檢查→去重通過→再渲染題面;答案直接復(fù)用上一步得到的數(shù)值,不再二次求值。
- 降低
Fraction構(gòu)造/比較的顆粒開銷
- 問題:時(shí)間占比中
fractions.__new__、_richcmp、isinstance占比高,說明頻繁新建/比較分?jǐn)?shù)。 - 改進(jìn):能直接讀取分子分母就別重復(fù)構(gòu)造;減少無(wú)意義的類型判斷;字符串化盡量用已有分子分母。
項(xiàng)目小結(jié)
做了什么
搭建了一個(gè)可生成四則運(yùn)算題并批改答案的命令行工具,模塊包括:rational、expr_ast、canon、generator、checker、io_utils、cli。
采用 TDD:先寫測(cè)試再實(shí)現(xiàn),覆蓋了整數(shù)/分?jǐn)?shù)/帶分?jǐn)?shù)解析、最小括號(hào)打印、等價(jià)去重、隨機(jī)生成約束、批改容錯(cuò)、I/O 嚴(yán)格格式與 CLI 退出碼。
進(jìn)行了基本效能分析(cProfile),識(shí)別生成鏈路的熱點(diǎn)并提出合并檢查與求值、延遲渲染、減少重試等改進(jìn)思路。
合作感想
接口先寫,評(píng)審再落地,這樣溝通成本更低;遇到分歧用測(cè)試用例對(duì)齊預(yù)期;代碼評(píng)審能及時(shí)發(fā)現(xiàn)問題點(diǎn)(格式兼容、退出碼映射、異常路徑)。本地環(huán)境/代理/權(quán)限問題集中記錄與復(fù)盤,少走重復(fù)彎路。
積累到的經(jīng)驗(yàn)
要先確定輸入輸出格式、異常類型與退出碼,后續(xù)改動(dòng)更穩(wěn)。先測(cè)后調(diào),抓最大頭(重復(fù)求值、過早渲染、無(wú)效重試),小步驗(yàn)證改動(dòng)收益。常用命令(pytest、覆蓋率、profile)腳本化,避免“沒保存/錯(cuò)環(huán)境/錯(cuò) PATH”的無(wú)效排障。README、運(yùn)行命令、目錄說明、樣例輸出,保證他人可復(fù)現(xiàn)與接手。

浙公網(wǎng)安備 33010602011771號(hào)