Github 项目地址
或者百度云下载单个项目 https://pan.baidu.com/s/1Vn0bPcQQExFoLnWcN1yv0Q 密码:ncaq
PSP(Personal Software Process)
PSP2.1 | PSP阶段 | 预估耗时实际耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 10 | 17 |
Estimate | 估计这个任务需要多少时间 | 5 | 10 |
Development | 开发 | 545 | 650 |
- Analysis | - 需求分析(包括学习新技术) | 120 | 160 |
- Design Spec | - 生成设计文档 | 60 | 90 |
- Coding Standard | - 代码规范 (为目前的开发制定合适的规范) | 5 | 10 |
- Design | - 具体设计 | 30 | 30 |
- Coding | - 具体编码 | 180 | 210 |
- Code Review | - 代码复审 | 30 | 30 |
- Test | - 测试(自我测试,修改代码,提交修改) | 120 | 120 |
Reporting | 报告 | 105 | 110 |
- Test Report | - 测试报告 | 60 | 90 |
- Size Measurement | - 计算工作量 | 15 | 20 |
- Postmortem & Process Improvement Plan | - 事后总结, 并提出过程改进计划 | 30 | 232 |
合计 | 665 | 787 |
解题思路
由于最近事情比较多,而且之前做过的词法分析器与之有很多类似的地方,可以复用解法。目测需求可能有一定的变更,预估完成时间应该不长,较晚才开始着手 Orz。
题目解读:精确定义问题和处理方法
实际上,要做一个代码数目统计的程序也并不简单,因为要了解不同的语言的一些语法特性。要做一个能统计所有种类的代码的数量的程序,会非常麻烦。譬如下图所示,许多现代语言的字符集的支持非常丰富,而采取的编码和计数方式缤纷多彩。
幸运的是,仔细阅读了老师的题目说明后——了解到老师的要求是:
一个用来检测只包含 ASCII 编码的 .c,.h 等文件的统计工具。
没有中文字符是在讨论区的答疑中看到的,故我个人目前详细定义为只包含 ASCII 编码。
其中 word 的统计方法为了简化工作,采用了比较费解的统计方法——这也导致了方便检测 .sh 类的文件(其语法中很多地方不允许空格分隔),故详细定义为 .c ,.h 等文件。还有一个问题是在代码中的字符串中如果出现 \n
,而代码本身没有换行,只算作字符,那么是算作转义字符,还是算两个正常字符呢?如果需要检测其他语言,像 .py 中单引号字符串 ''
,双引号字符串""
和多行字符串 """"""
是否需要支持,多行字符串是算作代码呢?还是算作注释呢?其行数又该如何定义和计算呢?
由于其他语言还涉及到一系列复杂的问题,所以我个人详细定义为只处理 .c 和 .h 文件。
- 转义字符的问题已提交讨论区,老师说不考虑转义字符。
- CRLF 字符的问题已提交讨论区,已找到解决方法。
跨平台也带来一些麻烦,对于换行,Windows 使用的是 CRLF \r\n
,而 macOS 等主流类 Unix 系统使用的是 \n
。在计算字符时应该如何处理。这些也没有在题目中详细定义。暂时只把这些问题交给 Java 本身。
最终的测试脚本,如果没有参数,或参数不合法,需要报怎样的错误提示,还是仅仅什么都不干,没有明确定义。如果我们在程序中写了提示,是否会导致老师测试失败,从而零分。
思路
基础功能
- 读取字符数
- 读取单词数
- 读取行数
- 输出文件
计数比较简单。如果不需要考虑处理字符串的问题,可以直接使用例如 aString.length()
等方法获取信息。如果需要考虑,就要便利字符串,分析计数。
扩展功能
-s
需要使用递归实现文件夹的遍历-a
需要分析语法信息,用 DFA 实现-e
需要创建一个屏蔽词的数据结构,在统计 word 时跳过这些屏蔽词
修改:编码构思
主要应用 OOP 的编程思想。
基础功能和扩展功能是否需要读取某个信息是 WordCounter 这个类的一个对象的具体属性。
wc.exe -c file.c //返回文件 file.c 的字符数
wc.exe -w file.c //返回文件 file.c 的单词总数
wc.exe -l file.c //返回文件 file.c 的总行数
wc.exe -o outputFile.txt //将结果输出到指定文件outputFile.txt
由上述要求可知是否需要统计某个信息,是由每一次的命令行参数确定的。那么我们就需要一些实例域来表示使用程序时,是否有那些命令参数。如下:
/** 表示是否有命令参数 {@code -c},即是否需要计算字符数,默认为否 */
private boolean requestChar = false;
/** 表示是否有命令参数 {@code -w},即表示是否需要计算单词数,默认为否 */
private boolean requestWord = false;
/** 表示是否有命令参数 {@code -l},即是否需要计算行数,默认为否 */
private boolean requestLine = false;
/**
* 表示是否有命令参数 {@code -o},即是否需要打印到指定的输出文件,默认为否
*/
private boolean requestOut = false;
/**
* 指定的输出文件名
* {@code -o} 命令参数和 outputFileName 必须成对出现
*/
private String outputFileName = null;
/**
* 表示是否有命令参数 {@code -s},默认为否
* 是否需要递归处理目录下符合条件的文件
*/
private boolean requestRecur = false;
/**
* 需要递归处理的文件夹名
*/
private String folderPath = null;
/**
* 表示是否有有命令参数 {@code -a},默认为否
* 是否需要返回更复杂的数据(代码行 / 空行 / 注释行)
*/
private boolean requestMore = false;
/**
* 表示是否有扩展功能 {@code -e},默认为否
* {@code -e stopList.txt} 是否需要提供文件,不计入单词统计
* {@code -e} 命令参数和 stopList.txt 必须成对出现
*/
private boolean requestIgnore = false;
/**
* 指定的停用词表名
*/
private String stopListName = null;
/** 停用词表 */
private HashSet<String> stopList = null;
根据需求,相关信息的常量——如用来匹配 .c 或 .h 这样的文件名的正则表达式是不会变的常量,故应设置为静态常量,如下:
/** 域显示初始化,匹配 {@code .c} 和 {@code .h} 文件的正则表达式字符串 */
private static final String COUNTABLE_FILE_REG_EX = "(\\w)+.[ch]";
/** 匹配 .txt 文件的正则表达式字符串 (输出文件,停用词表)*/
private static final String TXT_REG_EX = "(\\w)+.txt";
/** 匹配只含有 {@code _A-Za-z0-9} 文件夹名的正则表达式字符串 */
private static final String FOLDER_REG_EX = "(\\w)+";
而怎样读取数据,就设置为方法。如下:
代码说明
由于许多功能没有明确定义,目前看来,基础功能比较简单,就放在了一个类中 WordCounter。
重要变量详细说明
/**
* 需要计算的所有文件名,支持多个文件,默认为空,而非 {@code null}。
*/
private ArrayList<String> countFileNames = new ArrayList<>();
/**
* 需要计算的所有文件,默认为空,而非 {@code null}。
*/
private ArrayList<File> countFiles = new ArrayList<>();
/** 表示是否有命令参数 {@code -c},即是否需要计算字符数,默认为否 */
private boolean requestChar = false;
/** 表示是否有命令参数 {@code -w},即表示是否需要计算单词数,默认为否 */
private boolean requestWord = false;
/** 表示是否有命令参数 {@code -l},即是否需要计算行数,默认为否 */
private boolean requestLine = false;
/**
* 表示是否有命令参数 {@code -o},即是否需要打印到指定的输出文件,默认为否
*/
private boolean requestOut = false;
/**
* 指定的输出文件名
* {@code -o} 命令参数和 outputFileName 必须成对出现
*/
private String outputFileName = null;
/**
* 表示是否有命令参数 {@code -s},默认为否
* 是否需要递归处理目录下符合条件的文件
*/
private boolean requestRecur = false;
/**
* 需要递归处理的文件夹名
*/
private String folderPath = null;
/**
* 表示是否有有命令参数 {@code -a},默认为否
* 是否需要返回更复杂的数据(代码行 / 空行 / 注释行)
*/
private boolean requestMore = false;
/**
* 表示是否有扩展功能 {@code -e},默认为否
* {@code -e stopList.txt} 是否需要提供文件,不计入单词统计
* {@code -e} 命令参数和 stopList.txt 必须成对出现
*/
private boolean requestIgnore = false;
/**
* 指定的停用词表名
*/
private String stopListName = null;
/** 停用词表 */
private HashSet<String> stopList = null;
依赖方法说明
/**
* 判断是否是各种单词分隔符 (eg. ',', ' ', '\t', '\n')
* @param ch
* 被检测的字符
* @return {@code true} 如果 {@code ch} 是规定的分隔符, 否则返回 {@code false}
*/
private static boolean isWordSep(char ch) {
return (ch == ',' || ch == ' ' || ch == '\t' || ch == '\n');
}
/**
* 判断是否是各种空白字符 (eg. ' ', '\t', '\n')
* @param ch
* 被检测的字符
* @return {@code true} 如果 {@code ch} 是规定的空白字符, 否则返回 {@code false}
*/
private static boolean isBlank(char ch) {
return (ch == ' ' || ch == '\t' || ch == '\n');
}
/**
* 判断文件名是否和要求的正则表达式相匹配
*
* @param fileName
* 被判断的文件名
* @param fileRegEx
* 正则匹配格式
* eg. "(\\w)+.txt" *.txt, "(\\w)+.[ch]" *.c or *.h
* @return 如果文件名和要求的正则表达式匹配,返回 {@code true} ,否则 {@code false}
*/
private static boolean isFileMatch(String fileName, String fileRegEx) {
// .c & .h 文件的文件名正则表达式的模式
Pattern countablePattern = Pattern.compile(fileRegEx);
// System.out.println(fileName);
// 字符串为 null 或为空显然不匹配
if (fileName == null || fileName.equals("")) {
return false;
}
Matcher fileNameMacher = countablePattern.matcher(fileName);
return fileNameMacher.matches();
}
/**
* 递归遍历获取一个路径下的所有符合要求的文件,
* 匹配 {@code .c} 和 {@code .h} 文件,
* 匹配只有 {@code _A-Za-z0-9} 文件夹名。
* @param pathName
* 需要遍历的路径
* @param fileList
* 遍历获得的所有文件
*/
public static void getAllFile(String pathName, ArrayList<File> fileList) {
File tempFile = new File(pathName);
if (!tempFile.exists()) {
return;
}
else if (tempFile.isFile()
&& tempFile.getName().matches(COUNTABLE_FILE_REG_EX)) {
fileList.add(tempFile);
return;
}
else if (tempFile.isDirectory()
&& tempFile.getName().matches(FOLDER_REG_EX)) {
File[] files = tempFile.listFiles();
if (files != null) {
for (File f : files) {
// 一个目录下有多个文件,在一次调用中全部处理
// 以免不必要的递归
if (tempFile.isFile()
&& tempFile.getName().matches(COUNTABLE_FILE_REG_EX)) {
fileList.add(tempFile);
}
else if (tempFile.isDirectory()
&& tempFile.getName().matches(FOLDER_REG_EX)) {
getAllFile(f.getPath(), fileList);
}
}
}
}
}
功能方法说明
篇幅原因,只说明了部分方法。详情可见 Javadoc 和源代码。
/**
* 返回一个由给定的停用词表生成的 {@code HashSet<String>} 对象。
* @param pathName 给定的停用词表的路径名
* @return {@code null} 如果文件不存在或者不是 .txt 格式。{@code HashSet} 的对象,
* 如果文件存在且合法,但是为空,返回一个空的 {@code HashSet} 对象,否则返回
* 一个非空的 {@code HashSet} 对象。
*
* @since JDK1.7 使用了该版本才引进 {@code try with resources} 的语法
*
*/
private HashSet<String> getStopList(String pathName) {
HashSet<String> stopList = new HashSet<>();
File stopFile = new File(pathName);
// 1. 如果文件不存在或者不是 .txt 格式,返回 null
if (!stopFile.exists() || !isFileMatch(stopFile.getName(), TXT_REG_EX)) {
return null;
} else {
try (Scanner sc = new Scanner(stopFile)){
// 2.1 如果文件存在且合法,但是为空,返回一个空的 HashSet
// 这区别于情况 1,逻辑意义不同
// 2.2 否则返回一个非空的 HashSet
while (sc.hasNextLine()) {
String line = sc.nextLine();
String stopWords[] = line.split(" ");
for (String word : stopWords) {
stopList.add(word);
}
}
} catch (IOException e) {
e.printStackTrace();
}
return stopList;
}
}
/**
* 将统计出来的信息写入指定文件。
* <br>
* 使用了 {@code try with resources} 的特性,需要 JDK1.7+ 的支持。
* @since JDK1.7
* @param outputName 指定的文件路径
*/
public void writeOutput(String outputName) {
ArrayList<String> fileCounters = new ArrayList<>();
for (File file : this.countFiles) {
String aFileCounter = "";
if (requestChar) {
aFileCounter += ("字符数:" + this.countChar(file) + "\n");
}
if (requestWord) {
aFileCounter += ("单词数:" + this.countWord(file) + "\n");
}
if (requestLine) {
aFileCounter += ("行数:" + this.countLine(file) + "\n");
}
if (requestMore) {
long[] counts = this.countMoreInfo(file);
aFileCounter += ("代码行/空行/注释行:" + counts[0] + "/" +
counts[1] + "/" + counts[2] + "\n");
}
fileCounters.add(aFileCounter);
}
try (PrintWriter output = new PrintWriter(outputName, "UTF-8")) {
for (String str : fileCounters) {
output.print(str);
}
} catch (IOException e) {
e.printStackTrace();
}
}
测试设计过程
测试原理
采用课程中介绍的白盒测试用例设计方法来设计测试用例,在测试用例说明中都有具体的体现:
- 精确到函数来设计用例
- 测试边界
- 路径测试
单元测试
单元测试的结构
创建单元测试函数的主要步骤:
- 设置数据(一个假想的正确的E-mail地址);
- 使用被测试类型的功能(用E-mail地址来创建一个User类的实体);
- 比较实际结果和预期的结果(Assert.IsTrue(target!= null);)
记录测试用例
- 使用易读的测试用例名,见名知意。如最小/空测试
test/testCases/empty.c
。 - 在项目的 README.md 中详细说明。如最大单文件测试,来自 Lua 语言源代码中的解析器
test/testCases/lparser.c
- 在项目的 commit 中也写了测试概要
程序高风险说明
- 读写文件权限问题,如果在类 Unix 环境下,可能无写权限,导致
IOException
。 - 递归处理文件夹问题,由于使用了递归方法处理文件夹,如果路径过深,可能导致栈溢出。
- 文件创建失败,或不存在异常处理等
测试用例说明
下列共 14 个测试文件,基本覆盖了所有规定的、可能的路径。
- test
- testCases
- 1 emptyTest.c // 测试空文件 最小测试
- 2 hello.c // 普通程序测试
// 3 业界代码实况测试——Lua 语言的 parser 代码,50 KB,接近 2000 行 - 3 lparser.c
- 4 quoteTest1.c // 4 单独成行的注释测试
- 5 quoteTest2.c // 5 多行代码注释
- 6 quoteInString.c // 6 当 注释符号出现在字符串中,并不认为它是注释
- 7 newLine.c // 7 单个空行测试
- 8 newLines.c // 8 多个空行测试
- 9 crlfLfF.c // 9 跨平台换行测试
- 10 stopEmpty.c // 10 停用词表为空测试
- 11 stopList.c // 11 停用词表非空测试
- 12 iLegalStopList 12 非法停用词表测试
- 13 sqllite.txt // 13 ASCII C 工业项目下载地址 SQLLite 获取一个路径下的所有文件测试
- 14 deepRecur // 14 深目录测试
- testCases
参考资料
- Java 白皮书 version 9 & version 10
- 《Java 测试驱动测试》—— 图灵出版社
- Java SE API
- 邹欣老师关于单元测试的博客