HNU个人项目互评

发布时间 2023-09-20 23:10:39作者: 白水红叶

一、前言

这篇博客是对软件工程导论的个人项目进行互评,项目要求实现一个简单的中小学数学卷子自动生成程序。我的搭档谢先衍同学使用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

优点

  1. 清晰的代码结构: 代码使用了面向对象的方法,使用类来组织数据和功能,使代码具有良好的结构。
  2. 良好的注释和文档字符串: 代码中有注释和文档字符串,解释了类和方法的功能,这有助于其他开发人员理解代码。
  3. 封装性: 帐户数据和操作被封装在 Account​ 和 Accounts​ 类中,提高了代码的可维护性和可扩展性。

缺点

  1. 代码耦合度高: 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的多态性,允许不同子类提供不同的实现

缺点

  1. 部分魔法数值: 代码中出现了一些魔法数值,如 0.45、0.66 等,这些值没有明确的解释和注释,可能会导致代码的可读性和可维护性降低。最好将这些数值提取为常量,并提供相关注释。

  2. 文件操作错误处理不足: 代码中的文件操作没有足够的错误处理机制,如果文件无法创建或写入,代码会引发异常而无法处理。

  3. 生成题目的方法命名不一致: 不同级别的生成器子类中的 generate​ 方法签名不一致,这可能会导致混淆和错误。最好统一方法名。

  4. 未考虑边界情况: 代码中未考虑一些边界情况,如生成的数值范围、一元运算符的频率等,这可能导致生成的题目不够多样化或有问题。

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而言,代码更简练优美,希望大家有所启发。