2023北航面向对象先导总结

作业概述

写一个“冒险者“的游戏框架,经多次迭代开发,需要实现对冒险者的血量、金钱,拥有的各类药水瓶、装备、食物,携带的物品、雇佣者的维护和按不同关键字查找,以及开发商店,处理冒险者的交易行为。

架构设计及架构调整

数据的处理

在前三次作业中,我一看到输入格式,几乎都是<序号> <冒险者ID> <...>的格式,自然想到按照C语言的逻辑,在主方法中写一个for循环,然后内部使用case语句分别处理不同操作,按需读入。
但随着迭代,有三点原因使我不得不放弃这种架构。

  1. 操作类别增加,但checkstyle只允许单个方法不超过50行。最初的解决方法是开另开方法,main方法只处理操作 1 ∼ 10 1\sim10 110dealGroupTwo方法处理操作 11 ∼ 20 11\sim20 1120,然后再加……这种方式虽然丑陋,但是能减小代码改动。
  2. 作业中要求Junit测试对方法和分支的覆盖,然而Main里面含有标准输入,评测机又不允许通过重定向测试,所以主类的内容严重影响测试的覆盖率。尽管曾想过多开几个没用的方法蒙混过关,但是这违背了一个程序员的操守。
  3. 第四次作业要求通过解析正则表达式来处理战斗日志。这里打破了前期每个操作输入一行的惯例。而且正则表达式使用了Scanner.nextLine()方法。但是之前的读入都是nextnextInt,这就造成了一个漏洞nextnextInt都会将最后的空白符留在缓冲区,然后如果接着使用nextLine,这个空白字符就会被捕获,造成意想不到的错误。

这是最初的代码:

 public static void main(String[] args) throws IllegalStateException {
        adventurers = new HashMap<>();
        in = new Scanner(System.in);
        int n = in.nextInt();
        for (int i = 0; i < n; i++) {
            type = in.nextInt();
            advId = in.nextInt();
            if (type <= 10) {
                dealGroupOne();
            } else if (type <= 13) {
                dealGroupTwo();
            }
        }
        in.close();
    }

    public static void dealGroupOne() {
        if (type == 1) {
            String name = in.next();
            Adventurer tmpAdventurer = new Adventurer(advId, name);
            adventurers.put(advId, tmpAdventurer);
        } else if (type == 2) {
            int botId = in.nextInt();
            String botName = in.next();
            int capacity = in.nextInt();
            Adventurer tmpAdv = adventurers.get(advId);
            tmpAdv.addBottle(botId, botName, capacity);
        } //other types are omitted
    }

    public static void dealGroupTwo() {
        if (type == 11) {
            int foodId = in.nextInt();
            Adventurer tmpAdv = adventurers.get(advId);
            tmpAdv.bringFood(foodId);
        } else if (type == 12) {
            String name = in.next();
            Adventurer tmpAdv = adventurers.get(advId);
            tmpAdv.useBottle(name);
        } //other types are omitted
    }

基于三点问题,我最终决定重构输入与解析的逻辑。

  1. 读入与解析分离。一次性将所有信息读入。这种方式的缺点是占用较大内存,但是由于解析战斗日志的操作是不定行输入,不能一次只读一行。在作业环境下,输入是千行级别的,可以接受。
        ArrayList<String> inputInfo = new ArrayList<>();
        in = new Scanner(System.in);
        while (in.hasNextLine()) {
            inputInfo.add(in.nextLine());
        }
  1. 新开Minister类,处理解析输入的业务。为了解决case满天飞的丑态,我想c语言提供了函数指针,java中也必定有类似的实现。于是我查到了FunctionBiFunction这两种接口。Function<R, A>java.util.function.Function类中,是能够容纳单输入和单返回值的函数接口,R代表返回类型,A代表参数类型。我只需要传入已经分好段的字符串数组tokens,让不同方法处理各自的业务就好。返回值没什么用,可以返回0,作为正常返回的信号。
    下面列出了Minister的构造
	Minister(HashMap<Integer, Adventurer> advArg, HashMap<String, Adventurer> advInNameArg,
             TreeMap<String, ArrayList<Log>> logsInDateArg, ArrayList<String> inputArg) {
        this.adventurers = advArg;
        this.advInName = advInNameArg;
        this.logsInDate = logsInDateArg;
        this.store = Store.getInstance();
        this.input = inputArg;
        it = input.iterator();
        Function<String[], Integer>[] fp = new Function[24];
        fp[1] = this::addAdv;	//different types of operations
        fp[2] = this::addBot;
        fp[3] = this::rmBot;
        fp[4] = this::addEqu;
        fp[5] = this::rmEqu;
		//fp[6] to fp[23] are omitted 
		
        int number = Integer.parseInt(it.next().trim());	//first line, which contains number of operations
        for (int i = 0; i < number; i++) {	//parsing inputs
            String str = it.next().trim();	//using 'trim' to remove redundant space
            String[] tokens = str.trim().split(" +");	//spliting into tokens
            int type = Integer.parseInt(tokens[0]);
            if (type >= 1 && type <= 23) {
                fp[type].apply(tokens);	//executing
                errMsg = null;
            } else {
                errMsg = "Illegal type: " + type;
            }
        }
	}

再给出几种方法的实现。其中,addBot方法,由于不同类型的装备输入数据数量不同。我没有用if判断,而是直接try-catch,避免数错了tokens的长度造成错误。

    public int addAdv(String[] tokens) {
        int advId = Integer.parseInt(tokens[1]);
        String name = tokens[2];
        Adventurer tmpAdventurer = new Adventurer(advId, name);
        adventurers.put(advId, tmpAdventurer);
        advInName.put(name, tmpAdventurer);
        return 0;
    }

    public int addBot(String[] tokens) {
        int advId = Integer.parseInt(tokens[1]);
        int botId = Integer.parseInt(tokens[2]);
        String botName = tokens[3];
        int capacity = Integer.parseInt(tokens[4]);
        long price = Long.parseLong(tokens[5]);
        String type = tokens[6];
        Adventurer tmpAdv = adventurers.get(advId);
        String other;
        try {
            other = tokens[7];	//this attribute only exists in the derived classes of 'Bottle'
        } catch (ArrayIndexOutOfBoundsException e) {
            other = null;
        }
        tmpAdv.addBottle(botId, botName, capacity, price, type, other);
        return 0;
    }

    //......

继承和组合关系

梯形表示拥有。
在这里插入图片描述

冒险者的维护

在迭代中不断增加功能时,我的处理思路是,不断构建新的数据结构而非复用旧的,来适应不同的查找和修改需求。于是诞生了下面的attributes:

    private HashMap<Integer, Bottle> bottles;
    private HashMap<Integer, Equipment> equipments;
    private HashMap<Integer, Food> foods;
    private HashMap<String, Equipment> carriedEquipments;
    private HashMap<String, TreeSet<Bottle>> carriedBottles;
    private HashMap<String, TreeSet<Food>> carriedFoods;
    private ArrayList<Log> attLogs;
    private ArrayList<Log> vicLogs;
    private HashMap<Integer, Commodity> belongings;
    private HashSet<Adventurer> employees;
  1. 前三个是冒险者的拥有,用于以ID(整数)创建、查找和删除。
  2. 第四到第六个,是背包物品,按名字查找。这个要求比较复杂:装备要求每个重名的只能带一个,尝试携带同名装备直接替换原有的;食物和药水要求可以携带重名的物品,且使用时会使用id最小的一个。那就是用HsahMap将String映射到同名药水瓶或食物的集合。一说id最小,我的第一反应就是优先队列。即对于carriedBottlescarriedFoods,第二元使用PriorityQueue。但是优先队列最大的缺点就是难以查找和修改非最大元。于是我才用TreeSet。然后再写一个比较函数:
    private final Comparator<Food> cmpFood = Comparator.comparingInt(Food::getId);
    private final Comparator<Bottle> cmpBottle = Comparator.comparingInt(Bottle::getId);
  1. 第七到第八是处理以冒险者为关键字查询的日志。为适应查询冒险者作为攻击者和被攻击者的日志,直接开两个ArrayList,避免对每个日志进行判断,是攻击还是被攻击。
  2. 第九是对价值体的管理。按照题目对价值体的描述,我立刻认识到了应该是递归地计算价值体的价值。**复用接口中的getPrice方法,充分利用多态性。**对于冒险者,遍历belongings计算价值。
  3. 第十是为了实现被雇佣者的援助。这里是设计模式中的观察者模式,雇佣者向被雇佣者广播寻求援助。

提供援助:

    public long aidEmployer(long requiredMoney) {
        long actual = Long.min(requiredMoney, money);
        money -= actual;
        return actual;
    }

广播寻求援助:

    public void callAid() {
        if (hitPoint <= previousHp / 2) {
            int requiredMoney = (previousHp - hitPoint) * 10000;
            for (Adventurer tmpEmployee:
                    employees) {
                money += tmpEmployee.aidEmployer(requiredMoney);
            }
        }
    }

小结一下,这种不断增加数据结构以适应不同需求的设计哲学,能够保证对于每种需求,都能有最适合的数据结构相适应,减少了不必要的遍历和判断。但是,会造成代码冗杂的问题,然后bug就随之而来😭。我最终跌倒在了最后一次强测中,原因就是最后一个HashSet<Adventurer> employees. 最初我想的是既然是广播,那就用最简单的ArrayList就好。然后提交,一次性通过了中测,万事大吉。但是我忘了,题目要求“对于多次出现的雇佣关系仅算作一次雇佣”!(这是上一次迭代的要求),于是强测错了7个数据点。

商店的处理

采用单例模式,只允许有一个商店。静态类,隐藏构造方法,每次getInstance都返回同一个实例。

public class Store {
	private static Store store = new Store();   //one instance
    //declaration of attributes
    private Store() {
        //constructor
    }

    public static Store getInstance() {
        return store;
    }
}

使用junit的心得

  1. 刚接触时确实有许多不适应。直接构造测试数据,然后比对输出不好吗?
    后来我才知道,团队开发中,很可能拿不到主方法,只能通过junit进行局部测试。
  2. junit的确测试出了许多bug!junit测试出的bug,一般不是对题目的理解等大错误,而一般是字符串拼写错误、正则表达式写错等小错误。assertEquals防止肉眼看不出来。因此,写测试样例时,一定要从局部功能入手,不要从全局测试
  3. junit测试,从另一种程度上,强迫我重构代码,减少分支,使逻辑更加清晰。

学习oopre的心得体会

  1. 适应面向对象和工程化的思维。之前编程都是算法竞赛,精简语法以提高速度;降低可读性以提升效率。但是这种程序都是一次性的,之后不可能维护,更不可能迭代开发。
  2. 学会对自己的代码负责。自己测试代码,增加第一道防线。强测的机制,也让我认识到不能过分以来评测机。
  3. 关注代码风格。强制使用驼峰命名法,让我的命名前后一致,减少因命名不清或格式不同造成的错误,大大提高了代码开发效率。

建议

  1. 建议增加一些对Junit测试的指导,写出更强的测试数据;
  2. 建议增加读代码的训练,多见识专业人士写的规范代码。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值