作业概述
写一个“冒险者“的游戏框架,经多次迭代开发,需要实现对冒险者的血量、金钱,拥有的各类药水瓶、装备、食物,携带的物品、雇佣者的维护和按不同关键字查找,以及开发商店,处理冒险者的交易行为。
架构设计及架构调整
数据的处理
在前三次作业中,我一看到输入格式,几乎都是<序号> <冒险者ID> <...>
的格式,自然想到按照C语言的逻辑,在主方法中写一个for循环,然后内部使用case语句分别处理不同操作,按需读入。
但随着迭代,有三点原因使我不得不放弃这种架构。
- 操作类别增加,但
checkstyle
只允许单个方法不超过50行。最初的解决方法是开另开方法,main
方法只处理操作 1 ∼ 10 1\sim10 1∼10,dealGroupTwo
方法处理操作 11 ∼ 20 11\sim20 11∼20,然后再加……这种方式虽然丑陋,但是能减小代码改动。 - 作业中要求
Junit
测试对方法和分支的覆盖,然而Main
里面含有标准输入,评测机又不允许通过重定向测试,所以主类的内容严重影响测试的覆盖率。尽管曾想过多开几个没用的方法蒙混过关,但是这违背了一个程序员的操守。 - 第四次作业要求通过解析正则表达式来处理战斗日志。这里打破了前期每个操作输入一行的惯例。而且正则表达式使用了
Scanner.nextLine()
方法。但是之前的读入都是next
或nextInt
,这就造成了一个漏洞:next
或nextInt
都会将最后的空白符留在缓冲区,然后如果接着使用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
}
基于三点问题,我最终决定重构输入与解析的逻辑。
- 读入与解析分离。一次性将所有信息读入。这种方式的缺点是占用较大内存,但是由于解析战斗日志的操作是不定行输入,不能一次只读一行。在作业环境下,输入是千行级别的,可以接受。
ArrayList<String> inputInfo = new ArrayList<>();
in = new Scanner(System.in);
while (in.hasNextLine()) {
inputInfo.add(in.nextLine());
}
- 新开
Minister
类,处理解析输入的业务。为了解决case
满天飞的丑态,我想c语言提供了函数指针,java中也必定有类似的实现。于是我查到了Function
,BiFunction
这两种接口。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;
- 前三个是冒险者的拥有,用于以ID(整数)创建、查找和删除。
- 第四到第六个,是背包物品,按名字查找。这个要求比较复杂:装备要求每个重名的只能带一个,尝试携带同名装备直接替换原有的;食物和药水要求可以携带重名的物品,且使用时会使用id最小的一个。那就是用
HsahMap
将String映射到同名药水瓶或食物的集合。一说id最小,我的第一反应就是优先队列。即对于carriedBottles
和carriedFoods
,第二元使用PriorityQueue
。但是优先队列最大的缺点就是难以查找和修改非最大元。于是我才用TreeSet
。然后再写一个比较函数:
private final Comparator<Food> cmpFood = Comparator.comparingInt(Food::getId);
private final Comparator<Bottle> cmpBottle = Comparator.comparingInt(Bottle::getId);
- 第七到第八是处理以冒险者为关键字查询的日志。为适应查询冒险者作为攻击者和被攻击者的日志,直接开两个
ArrayList
,避免对每个日志进行判断,是攻击还是被攻击。 - 第九是对价值体的管理。按照题目对价值体的描述,我立刻认识到了应该是递归地计算价值体的价值。**复用接口中的
getPrice
方法,充分利用多态性。**对于冒险者,遍历belongings
计算价值。 - 第十是为了实现被雇佣者的援助。这里是设计模式中的观察者模式,雇佣者向被雇佣者广播寻求援助。
提供援助:
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的心得
- 刚接触时确实有许多不适应。直接构造测试数据,然后比对输出不好吗?
后来我才知道,团队开发中,很可能拿不到主方法,只能通过junit进行局部测试。 - junit的确测试出了许多bug!junit测试出的bug,一般不是对题目的理解等大错误,而一般是字符串拼写错误、正则表达式写错等小错误。assertEquals防止肉眼看不出来。因此,写测试样例时,一定要从局部功能入手,不要从全局测试。
- junit测试,从另一种程度上,强迫我重构代码,减少分支,使逻辑更加清晰。
学习oopre的心得体会
- 适应面向对象和工程化的思维。之前编程都是算法竞赛,精简语法以提高速度;降低可读性以提升效率。但是这种程序都是一次性的,之后不可能维护,更不可能迭代开发。
- 学会对自己的代码负责。自己测试代码,增加第一道防线。强测的机制,也让我认识到不能过分以来评测机。
- 关注代码风格。强制使用驼峰命名法,让我的命名前后一致,减少因命名不清或格式不同造成的错误,大大提高了代码开发效率。
建议
- 建议增加一些对Junit测试的指导,写出更强的测试数据;
- 建议增加读代码的训练,多见识专业人士写的规范代码。