软工实践寒假作业(2/2)
这个作业属于哪个课程 | 2021春软件工程实践S班 |
---|---|
这个作业要求在哪里 | 作业要求 |
这个作业的目标 | 进一步阅读《构建之法》提出问题,学习使用github,制定自己的代码规范,编写词频统计程序 |
其他参考文献 | CSDN、博客园... |
构建之法提问
问题1——如何避免过早地优化?
书53页:
过早优化:一个工程师在写程序的时候,经常容易在某一个局部问题上陷进去,花大量时间对其进行优化;无视这个模块对全局的重要性,甚至还不知道这个“全局”是怎么样的。这个毛病早就被归纳为“过早的优化是一切罪恶的根源”。
在网上搜索资料后得到一段话:优化之前先问三个问题,优化后代码量会减少吗?优化的是系统瓶颈吗?优化方案中,是否增加了隐藏条件和系统限制?
在这次的实际编码中,我也出现了这种过早优化的情况。当我在编写统计字符的时候,我发现我的读入文件非常缓慢,于是我去修改了IO流,然后IO流中的读入方法因为正确性又修改了一次,导致统计字符的功能的完成被拖后了很多。我认为在之后的优化过程中,先问自己这三个问题能够避免一部分的过早优化。
问题2——是否应该使用goto?
书75页:
goto
函数最好有单一的出口,为了达到这一目的,可以使用goto。只要有助于程序逻辑的清晰体现,什么方法都可以使用,包括goto。
我认为不应该使用goto,因为现在的许多高级语言都是结构化,模块化,如果使用goto在其中,必然破坏这种结构,导致程序更加难读且危险。
网上支持不支持的人都有,支持的人认为goto可以设计一个统一出口,简化代码。不支持的人认为goto容易把逻辑弄乱且难以理解。我个人还是认为不该使用goto,一旦代码量增多,维护这个统一出口也容易出现疏漏,从而导致的逻辑混乱则一个大项目是更加不可承受的。
问题3——结对编程两人水平相差较大应该怎么协调?
书85页
在结对编程模式下,一对程序员肩并肩、平等地、互补地进行开发工作。
我认为在学校这种环境下,有一个能跟你互补的程序员同学是一件比较小概率的事件,但两人水平差距大确实一个会出现的情况,这样两人在分工和交流上就会产生一定的隔阂。此前的一些合作项目,我都是在同学的带领之下学习相应知识然后完成分配给我的代码,感觉偏向任务驱动型,而体现不出结对编程的优势。
查阅了网络的资料发现,在这种强弱结对的情况下,通常类似于公司高级开发人员带新人,这种情况下,需要高级开发人员培养新人的信心和士气,让新人快速融入团队。所以我认为在学校的场景下,较强的同学带着较弱的同学一起学习一起共同完成工作能比较好地适应结对和团队编程。
问题4——如何提升用户填问卷的积极性和真实性?
书163页
用户调查问卷
这种方式是向用户提供事先设计好的问题,让用户回答。有时候用户在浏览某个网站时,一个弹窗会跳出来,打断用户的思路,不客气地要求用户回答几个问题。用户在回答这类问题时,是否会心不在焉,乱点一气?
在大学的日常生活中,经常有其他同学在朋友圈或者空间发填写调查问卷的链接,来号召朋友帮忙填写问卷,但去除朋友关系的作用,会有什么可以吸引我们的调查对象来填写问卷而且保证问卷真实呢?
查阅网络资料得到如下建议:
- 给予被调查者一定奖励激发参加的积极性。
- 强调调研的重要性,让用户有一种参与感和成就感。
- 明确调查目的和内容,以此为基础设计问卷。
问题5——为什么好的设计不一定赢?
书350页
如果使用QWERTY键盘,那么只有10%的英语单词能在手指不离开键盘中间行的情况下敲出来。但是如果使用Dvorak键盘,你可以在键盘中间行打出60%的常用单词!这样会减轻手指和相关肌肉的负担,减少劳损,同时加快打字速度。
人类在许多方面都在追求更高更好的性能,但在键盘上,高频字母更集中,效率更高的d键盘却在市场表现上远不如大众使用的q键盘,为什么会出现这样的情况?
查询互联网可知,d键盘在q键盘盛行的时候的学习成本太高,或是因为人的惰性,但我疑惑的是如果d键盘效率真有那么好为什么没有后发优势从而替代q键盘?
附加题——历史上第一个病毒
我是爬行者
爬行者的程序(Creeper),每一次把它读出时,它便自己复制一个副本。此外,它也会从一部电脑“爬”到另一部与其连网的电脑。很快地电脑中原有资料便被这些爬行者挤掉了。爬行者的唯一生存目地是繁殖。
爬行者的目标
Creeper由BBN Technologies 的开发人员Bob Thomas 创建,BBN Technologies处于新兴技术行业的前沿。托马斯编程爬行器测试是否可以创建一个程序在计算机之间移动。换句话说,他的想法并不是破坏个人电脑,实际上,仅仅几年之后,Creeper才认为是病毒,因为直到80年代这个概念才被应用到电脑上。
爬行者通过ARPANET (美国国防部使用的第一个计算机网络之一)传播并复制到系统中,在那里显示我在本文开头写的信息。一旦显示,它就开始打印文件,但随后停止并切换到另一台PC,并进行相同的处理。
尽管它在第一次出现时感染了电脑,但效果并没有持续太久:跳到下一台电脑,它从前一台电脑中消失,依此类推。
今天,这可能看起来像一个失败,但在1971年,鲍勃托马斯的实验得到了很多关注,因为在没有人为干预的情况下,获得程序在PC之间跳转的壮举是不可能实现的。他的成功是创建了一个从个人电脑到个人电脑快速运行的程序...... 并且是创建第一个防病毒 程序的原因!
WordCount程序
Github项目地址
PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 20 | 30 |
• Estimate | • 估计这个任务需要多少时间 | 4days | 5days |
Development | 开发 | 960 | 1490 |
• Analysis | • 需求分析 (包括学习新技术) | 30 | 60 |
• Design Spec | • 生成设计文档 | 20 | 20 |
• Design Review | • 设计复审 | 10 | 10 |
• Coding Standard | • 代码规范 (为目前的开发制定合适的规范) | 10 | 20 |
• Design | • 具体设计 | 30 | 40 |
• Coding | • 具体编码 | 600 | 1000 |
• Code Review | • 代码复审 | 60 | 100 |
• Test | • 测试(自我测试,修改代码,提交修改) | 200 | 240 |
Reporting | 报告 | 70 | 120 |
• Test Repor | • 测试报告 | 30 | 70 |
• Size Measurement | • 计算工作量 | 10 | 15 |
• Postmortem & Process Improvement Plan | • 事后总结, 并提出过程改进计划 | 30 | 35 |
合计 | 1050 | 1640 |
解题思路描述
词频统计可以大致分为下列模块
-
文件读取
传入输入文件路径的String,返回BufferedReader或者Stream
-
处理文档
将文件按行读取,存在字符串数组中,可以减少后续多次读文件的时间。
-
统计文件字符
遍历字符串数组,然后判断每个字符是否在ASCII码的范围内,累加符合ASCII码的字符数
但后续Q&A中看到不会出现非法字符,就采用累加字符串长度的方式。
-
统计单词总数
切分字符串,分离出每个词,再判断词语的格式,符合格式要求则累加单词总数。
-
统计有效行数
遍历字符串数组中的每一项 (每一项都是一行),再判断是否空行,累加非空行数。
-
统计单词出现次数
采用
Map<String, Integer>
存储单词和频数,后续排序首先按照频数再按字典序排即可符合要求最后取出排序后的前10个单词和频数。
-
-
结果输出到文件
传入输出文件的路径和要输出的信息字符串,将字符串输出到指定路径。
代码规范链接
程序设计与实现过程
类设计
FileIOUtil--文件IO操作类
public static class FileIOUtil {
//读入文件
public static BufferedReader readFile(String filePath)
//写入文件
public static void writeFile(String targetPath, String msg)
}
TextEditor--文件String处理类
public static class TextEditor {
//带参构造函数
public TextEditor(BufferedReader reader)
//文件读入字符串
public void readString()
//统计字符数
public int countAscii()
//判断有效单词
public static boolean validate(String word)
//统计单词数
public int countWords()
//统计top10单词
public String countTopWords()
//统计行数
public int countLines()
}
Core--核心接口类
public static class Core {
//统计获得字符数
public static int getCharsNum(TextEditor te)
//统计获得单词数
public static int getWordsNum(TextEditor te)
//统计获得top10单词的字符串
public static String getTopWords(TextEditor te)
//统计获得行数
public static int getLinesNum(TextEditor te)
}
WordCount
设置一些变量用来存储统计的结果
public class WordCount {
int charsNum, wordsNum, linesNum;
String topsStr = "";
// 输入输出文件路径参数 采用args[]传入读取
public static void main(String args[])
}
IO流选择与实现
选择:
因为是字符文件处理,所以选择了BufferedReader
起初认为采用按行读就能较好地完成读入文件的功能,选取了readline()方法,但在之后的字符串处理中发现readline()不能读到每行最后的换行符。因此改用了它的read()方法,改为按字符读入,虽然效率低与readline(),但是解决了换行符读入的问题。
实现:
reader为TextEditor创建时传入的BufferedReader对象
当读到字符为\n
时把当前字符串加入List,再清空。
reader读到末尾时退出循环,由于文件末尾没有\n
换行符,因此判断当前字符串是否为空,若非空则加入List,再清空stringBuilder。
public static BufferedReader readFile(String filePath) {
File file = new File(filePath);
FileInputStream inputStream = null;
InputStreamReader inputStreamReader = null;
BufferedReader bufferedReader = null;
try {
inputStream = new FileInputStream(file);
inputStreamReader = new InputStreamReader(inputStream);
bufferedReader = new BufferedReader(inputStreamReader);
} catch (FileNotFoundException e) {
System.out.println("找不到文件路径");
System.out.println("当前路径"+System.getProperty("user.dir"));
e.printStackTrace();
}
return bufferedReader;
}
数据结构选择
从文件读入的字符串,按照换行符区分按行存入List<String>
中
而单词的存储,由于要进行词频统计,那么map是一个非常不错的选择,可以方便地进行词频统计,于是采用了HashMap<String, Integer>
数据处理实现
-
统计字符数
由于输入中不会出现非法字符,因此将已经读入的strings的每行长度 (等于字符数) 累加。
public int countAscii() { int sum = 0; for (int i = 0; i < strings.size(); i++) { sum += strings.get(i).length(); } return sum; }
-
统计单词数&行数
因为没有独立出统计行数的需求,所以在统计单词的同时也统计非空行数,可以少遍历一次strings数组。
统计单词时将每一行,使用正则表达式
\W
匹配非单词数字的字符,再使用spilt函数切分字符串得到待验证的单词存入arr,再依次验证arr中的各项是否是有效单词 (使用validate(String)
函数),验证通过累加单词数。统计行数时,先用trim()
去除其中空格,再判断字符串是否为空。即可累加得到总行数。//统计单词数&行数 public int countWords() { String word; int linesSum = 0; int wordsSum = 0; for (int i = 0; i < strings.size(); i++) { //\W :匹配任何非单词数字字符,等价于 [^A-Z a-z 0-9_] String str = strings.get(i); String[] arr = str.split("\\W"); for (String s : arr) { if (validate(s)) { wordsSum++; } } if (!str.trim().isEmpty()) linesSum++; } lines = linesSum; return wordsSum; }
验证单词有效性,首先先判断长度,在满足长度要求情况下截取前四个字符,再利用正则表达式判断前四个字符中不含数字,若都满足则单词验证通过。
//验证单词有效性 public static boolean validate(String word) { boolean flag = true; final int MIN_LENGTH = 4; if (word.length() < MIN_LENGTH) { // System.out.println("单词长度小于" + MIN_LENGTH); return false; } String str = word.substring(0,4); Pattern p = Pattern.compile(".*\\d+.*"); Matcher m = p.matcher(str); if (m.matches()) { flag = false; //若有数字则是无效单词 } // System.out.println(str + "是否是有效单词" + flag); return flag; }
-
统计Top10单词
统计词频时,先构建单词和词频的map,再把map中的entrySet加入list,然后利用list的sort函数重载比较函数进行自定义排序。
之后利用StringBuilder将list的前十个单词和词频转成字符串并返回。
- 将单词转成小写,然后检测是否为合法单词,若合法再判断map是否已经包含该单词,若不含该单词则放入map并设频数为1;若已存在则将频数加1
- 将map放入List,以便调用sort函数。调用sort时重写比较方法。最后构建需要的Top10字符串
HashMap<String, Integer> words = new HashMap<String, Integer>(); List<Map.Entry<String, Integer>> list = new ArrayList<>(); public String countTopWords() { String word; int sum = 0; //将单词以及频数记录进map for (int i = 0; i < strings.size(); i++) { // \w :匹配包括下划线的任何单词字符,等价于 [A-Z a-z 0-9_] // \W :匹配任何非单词字符,等价于 [^A-Z a-z 0-9_] String[] arr = strings.get(i).split("\\W"); for (String str : arr) { word = str.toLowerCase(); //转小写 if (validate(word)) { if (!words.containsKey(word)) { words.put(word, 1); } else { int times = words.get(word) + 1; words.remove(word); words.put(word, times); } } } } //将map内容存入List等待排序 for (Map.Entry<String,Integer> entry : words.entrySet()) { list.add(entry); } //执行List的sort方法,并重写比较函数 list.sort(new Comparator<Map.Entry<String, Integer>>() { @Override public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) { if (o2.getValue().compareTo(o1.getValue()) == 0) { return o2.getKey().compareTo(o1.getKey()) * -1; } else { return o2.getValue() - o1.getValue(); } } }); //输出TOP10字符串的过程 stringBuilder.delete(0, stringBuilder.length()); for (int i = 0; i < Math.min(TOP_NUM, list.size()); i++) { stringBuilder.append(list.get(i).getKey()).append(": ").append(list.get(i).getValue()).append('\n'); } return stringBuilder.toString(); }
性能改进
采用带缓冲的BufferedReader,BufferedWriter
带有缓冲的BufferedReader会从物理流中一次性读取8k个字符,会尽量提取比当前操作更多的字符。
使用StringBuilder替换部分String
此前在readString()方法中大量使用String,又不断对String进行赋值和修改操作。导致后来的程序运行异常缓慢,后续查找资料了解到String和StringBuilder的区别。
-
String是字符串常量,所以值是不可变的,导致每次对String操作效率低下同时也耗费额外的内存空间。
例如,初值为"hello"的字符串str,在进行
str += " world";
时,会创建一个新的String值为"hello world",就把一个内存空间变成了三倍于原来的内存。 -
StringBuilder是字符串变量,该对象能够被多次修改,而不需要额外的内存空间。
因此可以把String用StringBuilder替换能大大提高代码性能。
public void readString() {
stringBuilder = new StringBuilder();
int value;
try {
while ((value = reader.read()) != -1) {
char ch = (char)value;
stringBuilder.append(ch); //原先采用 string += ch;的方式进行字符串拼接操作
if (ch == '\n') {
strings.add(stringBuilder.toString());
stringBuilder.delete(0, stringBuilder.length()); //替换了原先的string赋值操作
}
}
if (stringBuilder != null) {
strings.add(stringBuilder.toString());
stringBuilder.delete(0, stringBuilder.length()); //替换了原先的string赋值操作
}
} catch (IOException e) {
e.printStackTrace();
}
}
多线程并行计算
将字符统计、单词统计、词频统计分为三个线程进行,充分利用性能。
charThread.start();
wordsThread.start();
topThread.start();
测试数据:字符数5e8
行数1e6
大小523MB
- 未使用多线程
耗时: 1分34秒
- 启用多线程
耗时: 1分5秒
单元测试
单元测试采用JUnit4进行,单元测试中文件路径为方便调试采用硬编码,主程序文件路径是采用args参数读入。
Core类测试
-
测试统计字符数
要求:能正确统计所有ASCII码范围内的字符。
样例包含: 大小写英文、数字、空格、换行符以及部分符号。
@Test public void testGetCharsNum() throws Exception { String path = "MyTest1.txt"; String str = "hjkl123ACVjkl^^, ;abcd\nsz\r"; Lib.FileIOUtil.writeFile(path,str); BufferedReader br = new BufferedReader(new FileReader(path)); Lib.TextEditor te = new Lib.TextEditor(br); te.readString(); assertEquals(str.length(), Lib.Core.getCharsNum(te)); }
-
测试统计单词数
要求:统计所有长度4个字符以上且前四个字符不含数字的单词。
样例包含:相同单词、长度小于4的字符串、单词的大小写不同形式、前四个字符中含数字的字符串和穿插其中的换行符和符号。
@Test public void testGetWordsNum() throws Exception { String path = "MyTest2.txt"; BufferedWriter bw = new BufferedWriter(new FileWriter(path)); StringBuilder builder = new StringBuilder(); //包含相同单词、大小写形式单词以及数字穿插在单词中 以及换行 builder.append("abandon ").append("abandon "); builder.append("abc12|3 "); builder.append("abcd123 "); builder.append("abc) \n"); builder.append("123abc 1234565 "); builder.append("FILE file File "); bw.write(builder.toString()); bw.close(); BufferedReader br = new BufferedReader(new FileReader(path)); Lib.TextEditor te = new Lib.TextEditor(br); te.readString(); int result = Lib.Core.getWordsNum(te); assertEquals(6, result); }
-
测试统计词频
要求:能正确将合法单词按词频和字典序排序。
样例包含:非法单词、数字、不同字典序单词、大小写混合单词。
@Test public void testGetTopWordsNum() throws Exception { String path = "MyTest3.txt"; BufferedWriter bw = new BufferedWriter(new FileWriter(path)); //包含相同单词、大小写形式单词 同时混乱顺序 String str = "halo yep 123 StacK raW onetwothree afk PrOGrAm JavA jAVa pyThon premier Thor StarBucks Zofia ARUNI"; bw.write(str); bw.close(); BufferedReader br = new BufferedReader(new FileReader(path)); Lib.TextEditor te = new Lib.TextEditor(br); te.readString(); String result = Lib.Core.getTopWords(te); String ans = "java: 2\naruni: 1\nhalo: 1\nonetwothree: 1\npremier: 1\nprogram: 1\npython: 1\nstack: 1\nstarbucks: 1\nthor: 1\n"; assertEquals(ans, result); }
-
其他测试
-
单元测试覆盖率截图
对于93%的行都有覆盖到,剩余的一小部分为一些异常的catch块。
计算模块部分异常处理
一些基础IO流的try..catch..,对catch块进行了一些输出,方便之后确定位置。
心路历程与收获
-
这次的作业相比以往的大作业有许多的不同之处,让我从更多的角度更深入地了解了软件工程这门学科。
-
首先是通过使用github来进行代码同步以及版本管理,此前的大作业中,基本没有进行版本管理,就导致再出现一些故障需要版本回退的时候变得非常棘手,但使用git之后便可以灵活的进行版本控制,解决了此前的问题。
-
其次,这次的程序在性能调试上也花费了比以往更多的精力,优化前统计一个大文件经常卡死,没法顺利执行,但经过自己和查询资料的一些分析之后,修改了相应位置后提升性能,让程序能执行更大的文件,也感受到了不一样的成就感。
-
单元测试也是第一次使用,通过这种测试代码来检查自己代码的冗余程度和正确性确实是一种非常高效且实用的手段,能够更好地保证我们代码的质量。
-
在之后的作业中,也希望自己能进一步提升自己的代码水平和相应工具 (git、单元测试...)的使用水平