一、前言 這篇博客是對軟體工程導論的個人項目進行互評,項目要求實現一個簡單的中小學數學卷子自動生成程式。我的搭檔謝先衍同學使用Python完成了項目,而我則是使用java。儘管語言不同增加了一定的閱讀成本,但是接觸到另一種新語言並體會編程者發揮語言特性獨特的心得,確實是拓展了眼界。一個項目,最終歸結 ...
一、前言
這篇博客是對軟體工程導論的個人項目進行互評,項目要求實現一個簡單的中小學數學卷子自動生成程式。我的搭檔謝先衍同學使用Python完成了項目,而我則是使用java。儘管語言不同增加了一定的閱讀成本,但是接觸到另一種新語言並體會編程者發揮語言特性獨特的心得,確實是拓展了眼界。一個項目,最終歸結到不同問題,無論用什麼語言,面臨的問題都是一致的,但是語言的特性和編程者的思想卻是和而不同,由此給人以啟發
二、要求
用戶:
小學、初中和高中數學老師。
功能:
1、命令行輸入用戶名和密碼,兩者之間用空格隔開(程式預設小學、初中和高中各三個賬號,具體見附表),如果用戶名和密碼都正確,將根據賬戶類型顯示“當前選擇為XX出題”,XX為小學、初中和高中三個選項中的一個。否則提示“請輸入正確的用戶名、密碼”,重新輸入用戶名、密碼;
2、登錄後,系統提示“準備生成XX數學題目,請輸入生成題目數量(輸入-1將退出當前用戶,重新登錄):”,XX為小學、初中和高中三個選項中的一個,用戶輸入所需出的卷子的題目數量,系統預設將根據賬號類型進行出題。每道題目的操作數在1-5個之間,操作數取值範圍為1-100;
3、題目數量的有效輸入範圍是“10-30”(含10,30,或-1退出登錄),程式根據輸入的題目數量生成符合小學、初中和高中難度的題目的卷子(具體要求見附表)。同一個老師的卷子中的題目不能與以前的已生成的卷子中的題目重覆(以指定文件夾下存在的文件為準,見5);
4、在登錄狀態下,如果用戶需要切換類型選項,命令行輸入“切換為XX”,XX為小學、初中和高中三個選項中的一個,輸入項不符合要求時,程式控制台提示“請輸入小學、初中和高中三個選項中的一個”;輸入正確後,顯示“”系統提示“準備生成XX數學題目,請輸入生成題目數量”,用戶輸入所需出的卷子的題目數量,系統新設置的類型進行出題;
5、生成的題目將以“年-月-日-時-分-秒.txt”的形式保存,每個賬號一個文件夾。每道題目有題號,每題之間空一行;
個人項目9月17日晚上10點以前提交至創新課程管理系統。提交方式:工程文件打包,壓縮包名為“幾班+姓名.rar”。遲交2天及以內者扣分,每天扣20%。遲交2天及以上者0分。
附表-1:賬號密碼
賬戶類型 | 賬戶 | 密碼 | 備註 |
---|---|---|---|
小學 | 張三1 | 123 | |
張三2 | 123 | ||
張三3 | 123 | ||
初中 | 李四1 | 123 | |
李四2 | 123 | ||
李四3 | 123 | ||
高中 | 王五1 | 123 | |
王五2 | 123 | ||
王五3 | 123 |
附表-2:小學、初中、高中題目難度要求
小學 | 初中 | 高中 | ||
---|---|---|---|---|
難度要求 | +,-,*./ | 平方,開根號 | sin,cos,tan | |
備註 | 只能有+,-,*./和() | 題目中至少有一個平方或開根號的運算符 | 題目中至少有一個sin,cos或tan的運算符 |
三、功能檢查
背景
環境:Python 3.11.5
軟體:VScode
登錄
命令行輸入用戶名和密碼,兩者之間用空格隔開(程式預設小學、初中和高中各三個賬號,具體見附表),如果用戶名和密碼都正確,將根據賬戶類型顯示“當前選擇為XX出題”,XX為小學、初中和高中三個選項中的一個。否則提示“請輸入正確的用戶名、密碼”,重新輸入用戶名、密碼
成功登錄的情況
測試:錯誤的賬號密碼
在錯誤的情況下,提示“請輸入正確的用戶名、密碼”
測試:不符合格式的賬號密碼
在輸入含多個空格的輸入後,判斷格式錯誤
出題
登錄後,系統提示“準備生成XX數學題目,請輸入生成題目數量(輸入-1將退出當前用戶,重新登錄):”,XX為小學、初中和高中三個選項中的一個,用戶輸入所需出的卷子的題目數量,系統預設將根據賬號類型進行出題。每道題目的操作數在1-5個之間,操作數取值範圍為1-100;
題目數量的有效輸入範圍是“10-30”(含10,30,或-1退出登錄),程式根據輸入的題目數量生成符合小學、初中和高中難度的題目的卷子(具體要求見附表)。同一個老師的卷子中的題目不能與以前的已生成的卷子中的題目重覆
生成的題目將以“年-月-日-時-分-秒.txt”的形式保存,每個賬號一個文件夾。每道題目有題號,每題之間空一行;
成功出題
成功按指定數目出題,並且附帶題號,題與題之間空有一行,在對應的用戶文件夾之下生成以時間為名的題目文件
退出
成功退出到上一頁面
測試:不在範圍內的輸入
不在範圍內的輸入會提示範圍
測試:不規範的輸入
不和規範的輸入會被認為是字元串,進而判斷是否是切換選項
測試:間隔少於1s的快速輸入
存放生成題目的文件是按時間命名的,最低單位是秒。如果快速輸入,1秒中輸入多次呢?
結果是生成了第一次的文件
切換
成功切換
生成了不同類型的題目,併成功存入對應的目錄之下
測試:錯誤輸入
匹配的字元串只有三種,此外的字元串都會觸發提示
總結
功能的測試完備,符合文檔的需求,可以說作者在編寫代碼的時候非常嫻熟精準,落實到了文檔的每一個功能實現之中。雖然在一個測試中出現了點小問題,但考慮到並非文檔指明的需求,無傷大雅。
四、代碼分析
代碼
account.py
#!/usr/bin/env python3.10.9
# -*- coding: utf-8 -*-
import json
class Account(object):
"""Account class.
Attributes:
account: The account, such as '張三1'.
password: The password of the account.
grade: The corresponding grade of the account, to generate exam with
different difficuty.
"""
def __init__(self, account, password, grade) -> None:
"""Init the account."""
self.account = account
self.password = password
self.grade = grade
class Accounts(object):
"""Accounts class, to manage accounts.
Attributes:
accounts: The accounts list, read from accounts.json.
"""
def __init__(self) -> None:
"""Read accounts from accounts.json to init the accounts list."""
self.accounts = []
with open("accounts.json", "r", encoding="utf-8") as f:
accounts_dir = json.loads(f.read())
for key in accounts_dir.keys():
for account in accounts_dir[key]:
self.accounts.append(
Account(account["account"], account["password"], key))
def check_account(self, account, password) -> str:
for acc in self.accounts:
if acc.account == account and acc.password == password:
return acc.grade
return None
def login(self) -> (str, str):
"""Login to the system.
Returns:
account: The account try to login in.
grade: The corresponding grade of the account.
"""
while True:
account_passwd = input("請輸入用戶名和密碼,兩者之間用空格隔開: ")
try:
account, passwd = account_passwd.split(" ")
except:
print("格式錯誤!")
continue
grade = self.check_account(account, passwd)
if grade is None:
print("請輸入正確的用戶名、密碼!")
else:
return account, grade
優點
- 清晰的代碼結構: 代碼使用了面向對象的方法,使用類來組織數據和功能,使代碼具有良好的結構。
- 良好的註釋和文檔字元串: 代碼中有註釋和文檔字元串,解釋了類和方法的功能,這有助於其他開發人員理解代碼。
- 封裝性: 帳戶數據和操作被封裝在
Account
和Accounts
類中,提高了代碼的可維護性和可擴展性。
缺點
- 代碼耦合度高:
Accounts
類直接依賴於文件 I/O 和 JSON 解析,這導致了代碼的耦合度較高。最好將這些依賴項解耦,以便更容易進行單元測試和擴展。
examgenerator.py
#!/usr/bin/env python3.10.9
# -*- coding: utf-8 -*-
from abc import ABC
from abc import abstractmethod
import os
import random
import re
import time
class ExamGenerator(ABC):
"""Abstract class for exam generator.
Attributes:
operators: The operators to use.
"""
def __init__(self) -> None:
self.operators = ["+", "-", "*", "/"]
@abstractmethod
def generate(self, num_range: tuple) -> str:
"""
Generate a math problem.
"""
def unary_op(self, op: str, obj: str) -> str:
"""Unary operator such as ^2, sqrt, sin, cos, tan.
If the operator is ^2, then the operator is behind the object, if the
operator is sqrt, sin, cos or tan, then the operator is before the
object. Otherwise, the operator is not used.
Arguments:
op: The unary operator to use.
obj: The object to use the unary operator.
Returns:
the result str of the object with the unary operator.
"""
if op == "^2":
return f"({obj})^2"
elif op in ["sqrt", "sin", "cos", "tan"]:
return f"{op}({obj})"
else:
return obj
def reverse(self, prob: str) -> str:
"""Reverse the prob str if it's operators number is 2.
Arguments:
prob: The prob str to reverse.
Returns:
the reversed prob str.
"""
pattern = r'\d+' # Match the number using regular expression
prob = prob.replace("^2", "^") # To avoid the ^2 operator being matched
matches = re.findall(pattern, prob)
if len(matches) == 2:
prob = prob.replace(matches[1], matches[0])
prob = prob.replace(matches[0], matches[1], 1)
prob = prob.replace("^", "^2")
return prob
def check_repeat(self, account: str, prob: str) -> bool:
"""Check if the prob is repeated.
Arguments:
account: The account using the program.
prob: The prob str to check.
Returns:
True if the prob is repeated, otherwise False.
"""
if os.path.exists(f"exams/{account}"):
for file in os.listdir(f"exams/{account}"):
with open(f"exams/{account}/{file}", "r") as f:
lines = f.readlines()[:-1:2] # Remove the '\n'
for line in lines:
if (prob == line[4:-1] or # Remove the prob index and space and '\n'
self.reverse(prob) == line[4:-1]):
return True
return False
def save_probs(self, account: str, probs_num: int) -> None:
"""Save the probs to the file.
Arguments:
account: The account using the program.
probs_num: The number of probs to generate.
Returns:
None.
"""
probs = []
for i in range(probs_num):
while True:
prob = f"{self.generate()}="
if ((prob not in probs) and
(not self.check_repeat(account, prob))):
break
probs.append(prob)
if not os.path.exists(f"exams/{account}"):
os.mkdir(f"exams/{account}")
# filename: 年-月-日-時-分-秒.txt
filename = f"{time.strftime('%Y-%m-%d-%H-%M-%S', time.localtime())}.txt"
with open(f"exams/{account}/{filename}", "w") as f:
index = 1
for prob in probs:
space = " " if index < 10 else " " # Add space to align the index
f.write(f"{index}.{space}{prob}\n\n")
index += 1
class ExamGenerator1(ExamGenerator):
"""Exam generator for primary school."""
def __init__(self) -> None:
super().__init__()
def generate(self, nums_range=(1, 5)) -> str:
op_num = random.randint(nums_range[0], nums_range[1])
op = random.choice(self.operators)
if op_num == 1:
if nums_range[1] == 5:
return self.generate((1, 5)) # If the result is only 1 number, generate again
else:
return str(random.randint(1, 100))
elif op_num == 2:
left_op = self.generate((1, 1))
right_op = self.generate((1, 1))
if random.random() < 0.45 and nums_range[1] != 5:
return f"({left_op}{op}{right_op})" # 45% to add brackets
else:
return f"{left_op}{op}{right_op}"
else:
left_op = self.generate((1, op_num // 2))
right_op = self.generate((1, op_num - op_num // 2))
if random.random() < 0.45 and nums_range[1] != 5:
return f"({left_op}{op}{right_op})"
else:
return f"{left_op}{op}{right_op}"
class ExamGenerator2(ExamGenerator):
"""Exam generator for junior high school."""
def __init__(self) -> None:
super().__init__()
self.operators.extend(["^2", "sqrt"])
def generate(self, nums_range=(1, 5)) -> str:
op_num = random.randint(nums_range[0], nums_range[1])
op = random.choice(self.operators)
op1 = random.choice(self.operators[:4])
if op_num == 1:
if op in self.operators[4:] and random.random() < 0.66:
result = self.unary_op(op, str(random.randint(1, 100)))
else:
result = str(random.randint(1, 100))
elif op_num == 2:
left_op = self.generate((1, 1))
right_op = self.generate((1, 1))
if op in ["^2", "sqrt"]:
return self.unary_op(op, f"{left_op}{op1}{right_op}")
else:
result = f"{left_op}{op}{right_op}"
else:
left_op = self.generate((1, op_num // 2))
right_op = self.generate((1, op_num - op_num // 2))
if op in ["^2", "sqrt"]:
result = self.unary_op(op, f"{left_op}{op1}{right_op}")
else:
result = f"{left_op}{op}{right_op}"
if (nums_range[1] == 5 and result.find("sqrt") == -1 and
result.find("^2") == -1): # If the result don't contain sqrt or ^2, generate again
return self.generate((1, 5))
return result
class ExamGenerator3(ExamGenerator):
"""Exam generator for senior high school."""
def __init__(self) -> None:
super().__init__()
self.operators.extend(["^2", "sqrt", "sin", "cos", "tan"])
def generate(self, nums_range=(1, 5)) -> str:
op_num = random.randint(nums_range[0], nums_range[1])
op = random.choice(self.operators)
op1 = random.choice(self.operators[:4])
if op_num == 1:
if op in self.operators[4:] and random.random() < 0.66:
result = self.unary_op(op, str(random.randint(1, 100)))
else:
result = str(random.randint(1, 100))
elif op_num == 2:
left_op = self.generate((1, 1))
right_op = self.generate((1, 1))
if op in self.operators[4:]:
result = self.unary_op(op, f"{left_op}{op1}{right_op}")
else:
result = f"{left_op}{op}{right_op}"
else:
left_op = self.generate((1, op_num // 2))
right_op = self.generate((1, op_num - op_num // 2))
if op in self.operators[4:]:
result = self.unary_op(op, f"{left_op}{op1}{right_op}")
else:
result = f"{left_op}{op}{right_op}"
if (nums_range[1] == 5 and result.find("sin") == -1 and
result.find("cos") == -1 and result.find("tan") == -1): # If the result don't contain sin, cos or tan, generate again
return self.generate((1, 5))
return result
優點
1 抽象類和多態: ExamGenerator
是一個抽象基類,定義了一個抽象方法 generate
,並且在子類中進行了實現。這利用了Python的多態性,允許不同子類提供不同的實現
缺點
-
部分魔法數值: 代碼中出現了一些魔法數值,如 0.45、0.66 等,這些值沒有明確的解釋和註釋,可能會導致代碼的可讀性和可維護性降低。最好將這些數值提取為常量,並提供相關註釋。
-
文件操作錯誤處理不足: 代碼中的文件操作沒有足夠的錯誤處理機制,如果文件無法創建或寫入,代碼會引發異常而無法處理。
-
生成題目的方法命名不一致: 不同級別的生成器子類中的
generate
方法簽名不一致,這可能會導致混淆和錯誤。最好統一方法名。 -
未考慮邊界情況: 代碼中未考慮一些邊界情況,如生成的數值範圍、一元運算符的頻率等,這可能導致生成的題目不夠多樣化或有問題。
main.py
#!/usr/bin/env python3.10.9
# -*- coding: utf-8 -*-
from account import Accounts
from examgenerator import ExamGenerator
from examgenerator import ExamGenerator1
from examgenerator import ExamGenerator2
from examgenerator import ExamGenerator3
def Exam_generator(grade: str) -> ExamGenerator:
"""Return the corresponding ExamGenerator according to the grade.
Arguments:
grade: The grade of the account.
Returns:
The corresponding ExamGenerator.
"""
if grade == "小學":
return ExamGenerator1()
elif grade == "初中":
return ExamGenerator2()
elif grade == "高中":
return ExamGenerator3()
else:
return ExamGenerator()
def main():
"""Main function of the program."""
accounts = Accounts()
account, grade = accounts.login()
exam_generator = Exam_generator(grade)
prob_num = 0
while True:
try:
prob_num = input(
f"準備生成{grade}數學題目,請輸入生成題目數量(輸入-1將退出當前用戶重新登錄,輸入切換為XX可以切換身份): ")
if prob_num.startswith("切換為"):
if prob_num[3:] in ["小學", "初中", "高中"]:
grade = prob_num[3:]
exam_generator = Exam_generator(grade)
else:
print("請輸入小學、初中和高中三個選項中的一個!")
elif int(prob_num) >= 10 and int(prob_num) <= 30:
exam_generator.save_probs(account, int(prob_num))
print(f"{grade}數學題目生成完畢,已保存到exams/{account}目錄下!")
elif int(prob_num) == -1:
account, grade = accounts.login()
exam_generator = Exam_generator(grade)
else:
print("請輸入10-30之間的數字")
except ValueError:
print("請輸入10-30之間的數字或切換為小學、初中和高中三個選項中的一個!")
if __name__ == '__main__':
main()
accounts.json
{
"小學": [
{
"account": "張三1",
"password": "123"
},
{
"account": "張三2",
"password": "123"
},
{
"account": "張三3",
"password": "123"
}
],
"初中": [
{
"account": "李四1",
"password": "123"
},
{
"account": "李四2",
"password": "123"
},
{
"account": "李四3",
"password": "123"
}
],
"高中": [
{
"account": "王五1",
"password": "123"
},
{
"account": "王五2",
"password": "123"
},
{
"account": "王五3",
"password": "123"
}
]
}
優點
模塊化設計: 代碼使用了模塊化的設計,將不同功能的代碼分別放在了不同的模塊(account
和 examgenerator
)中,提高了代碼的可維護性和可重用性。
Google代碼規範
大體遵守了Google Python代碼規範:
1. 模塊和函數命名
代碼中的模塊和函數命名在大多數情況下是清晰和符合規範的。例如,Accounts
類和 ExamGenerator
抽象類的命名是符合規範的。
2. 函數參數類型註釋
在一些方法中,使用了參數類型的註釋,這有助於理解參數的預期類型。這是符合Google代碼規範的一項實踐。
def generate(self, num_range: tuple) -> str:
"""
Generate a math problem.
"""
...
def check_repeat(self, account: str, prob: str) -> bool:
"""Check if the prob is repeated.
Arguments:
account: The account using the program.
prob: The prob str to check.
Returns:
True if the prob is repeated, otherwise False.
"""
...
3. 異常處理
碼中有一些異常處理,這是符合Google代碼規範的一項實踐。異常處理有助於處理潛在的錯誤情況。
try:
# 異常處理代碼
except ValueError:
print("請輸入10-30之間的數字或切換為小學、初中和高中三個選項中的一個!")
也有值得改進的地方:
文檔字元串(Docstrings)
代碼中缺少文檔字元串(Docstrings)。文檔字元串是對模塊、類、函數和方法功能的詳細描述,以及參數和返回值的說明。這是Google代碼規範強烈鼓勵的一項實踐,有助於提高代碼的可讀性和可維護性。
class Account(object):
"""Account class.
Attributes:
account: The account, such as '張三1'.
password: The password of the account.
grade: The corresponding grade of the account, to generate exam with
different difficuty.
"""
...
class Accounts(object):
"""Accounts class, to manage accounts.
Attributes:
accounts: The accounts list, read from accounts.json.
"""
...
五、總結
第一次寫博客評價他人的項目,我深切感受到了寫代碼還得是一個團隊活動,人和人之間交流彼此的意見和經驗,以求共同進步,這樣的學習方式更加高效。搭檔的項目寫得很好,相比我寫的java而言,代碼更簡練優美,希望大家有所啟發。