文章目录
前言
这篇文章用于记录我在软件构造这门课程的实验中的有价值的问题及其解决方法,并对这三个实验的收获进行总结。
希望能对遇到这些问题的朋友们有所帮助。
Lab-1
文件写入为空
- 问题:在实验过程3.1.2中,需要将生成的奇数阶幻方矩阵存入指定的.txt文件中。经测试发现.txt文件内容为空。
- 解决方法:查找资料,得知空文件的问题可能是由于写入操作未及时刷新到磁盘所致。在使用 FileWriter 写入文件时,有可能数据会被缓冲在内存中,并不会立即写入到磁盘。所以在完成写入操作后,使用.close()方法关闭文件流,便可以自动刷新缓冲区数据,将其写入磁盘。
/**
* txt文件写入函数
* 将生成的矩阵写入指定txt文件
* @param array 要写入的整数数组
* @param filePath txt文件的地址
*/
public static void writeArrayToTxt(int[][] array, String filePath)
{
FileWriter writer = null;
try
{
writer = new FileWriter(filePath);
for (int i = 0; i < array.length; i++)
{
for (int j = 0; j < array[i].length; j++)
{
writer.write(array[i][j] + "\t");
}
writer.write("\n"); // 写入换行符
}
writer.close(); // 这里一定要及时关闭文件流,否则文件内容为空!
System.out.println("数组已成功写入文件:" + filePath);
}
catch (IOException e)
{
System.out.println("写入文件时出错:" + e.getMessage());
}
}
这里一定要注意 writer.close();这一行语句。只有关闭掉才可以成功写入。
这是我在实验1时的代码,现在看来,将这行代码放在finally块中更好,
可以保证文件写指针成功关闭。
Java Math的三角函数使用
- 问题:在实验过程3.2中,计算方位角时测试无法通过。
- 解决方法:经过debug,发现java的Math中,三角函数使用弧度制来计算。所以需要先将度数转换为弧度。而且acos函数的返回值范围在0到π之间,需要手动修正结果范围为0到360之间。
/**
* Given the current direction, current location, and a target location, calculate the Bearing
* towards the target point.
*
* The return value is the angle input to turn() that would point the turtle in the direction of
* the target point (targetX,targetY), given that the turtle is already at the point
* (currentX,currentY) and is facing at angle currentBearing. The angle must be expressed in
* degrees, where 0 <= angle < 360.
*
* HINT: look at http://en.wikipedia.org/wiki/Atan2 and Java's math libraries
*
* @param currentBearing current direction as clockwise from north
* @param currentX current location x-coordinate
* @param currentY current location y-coordinate
* @param targetX target point x-coordinate
* @param targetY target point y-coordinate
* @return adjustment to Bearing (right turn amount) to get to target point,
* must be 0 <= angle < 360
*/
public static double calculateBearingToPoint(double currentBearing, int currentX, int currentY,
int targetX, int targetY) {
//throw new RuntimeException("implement me!");
//使用向量的夹角方法计算方位角
//首先表示出两个向量,记为a和b,a=(aX,aY),b=(bX,bY);
double aX = Math.sin(Math.toRadians(currentBearing));
double aY = Math.cos(Math.toRadians(currentBearing));
double bX = targetX - currentX;
double bY = targetY - currentY;
//使用向量夹角公式计算夹角angle的cos值
double dotProduct = aX*bX + aY*bY;
double magnitudeA = Math.sqrt(aX*aX+aY*aY);
double magnitudeB = Math.sqrt(bX*bX+bY*bY);
// 计算夹角
double cosAngle = dotProduct / (magnitudeA * magnitudeB);
double angle = Math.acos(cosAngle); // 结果以弧度为单位
// 将弧度转换为度
double degrees = Math.toDegrees(angle);
// 将角度转换到 0 到 360 度之间
if (bX * aY - bY * aX < 0) {
degrees = 360 - degrees;
}
return degrees;
}
这里注意的细节就是要使用double degrees = Math.toDegrees(angle);将弧度转化为度
由于结果可能是负数(反向转动),需要用360-结果 来得到正值
凸包算法
-
问题:无法通过“三点共线”情况的测试
-
解决方法:通过分析所给的测试用例,发现题目网页指向的Wiki上该算法的伪代码并未考虑到如何处理凸包边缘上的点,也就是“三点共线”的情况。于是进行修改,将共线且符合要求的点存入一个新的集合中,选择距离上一个凸包顶点最远的点作为新的凸包顶点。测试通过。
-
问题:在实验过程3.2中,凸包算法无法通过测试:抛出了null有关异常。
-
解决方法:使用断点调试,并查看变量变化,最终确定是标识变量isSet没有及时更新,以及没有处理多个点重合的情况导致集合pointsOnHull异常置空。通过重新修改if语句的逻辑以及对变量isSet更新语句位置的调整最终解决该问题。
这里的凸包算法是我自己基于wiki百科的伪代码编写的,性能不算优秀,但经过我自己的测试,它能够通过很多种情况,尤其是像三点共线这样边界情况的检验。中间比较复杂的逻辑部分已经体现在注释中。
/**
* Given a set of points, compute the convex hull, the smallest convex set that contains all the points
* in a set of input points. The gift-wrapping algorithm is one simple approach to this problem, and
* there are other algorithms too.
*
* @param points a set of points with xCoords and yCoords. It might be empty, contain only 1 point, two points or more.
* @return minimal subset of the input points that form the vertices of the perimeter of the convex hull
*/
public static Set<Point> convexHull(Set<Point> points) {
//throw new RuntimeException("implement me!");
//首先考虑特殊情况,如果集合中有0,1,2,3个点,那么凸包即为它本身
if(points.size()<=3)
{
return points;
}
//,若在三个点以上,则选出最左下的点,其必在凸包中
Point left = new Point(Double.POSITIVE_INFINITY, Double.POSITIVE_INFINITY);
for(Point point:points)
{
if(point.x()<=left.x()&&point.y()<=left.y())
{
left = point;
}
}
//初始化变量
Point endPoint = null;//末端点
Point pointOnHull = left;//当前能找到的最左侧点,也是最先加入凸包中的点
Set<Point> convexHullPoints = new HashSet<Point>();//凸包集合
double v1X,v1Y,v2X,v2Y;//两个向量的坐标表示。用于判断左侧右侧
Set<Point> pointsOnHull = new HashSet<Point>();//需要加入凸包的点集合,存储凸包边缘共线的多个点
Boolean isSet = false;//是否出现凸包边缘共线的情况
Point pastPoint = null;//存储上一个加入凸包的顶点,用于共线情况
while(endPoint!=left)
{
//判断共线情况
if(!isSet)
{
convexHullPoints.add(pointOnHull);
}
else //这种情况下,遍历该集合,找出离pastPoint最远的点加入
{
double max = 0;
Point farPoint = null;
for(Point p:pointsOnHull)
{
double diff = Math.abs(p.x()-pastPoint.x())+Math.abs(p.y()-pastPoint.y());
if(diff > max)
{
max = diff;
farPoint = p;
}
}
pointOnHull = farPoint;
convexHullPoints.add(pointOnHull);
}
isSet = false;//这里必须重置isSet为false,否则上次isSet的值可能会保留
pointsOnHull.clear();//最后需要清空该集合
//下面需要选出一点作为endpoint,但该点不能与pointHull重合
//从集合中抽出一点需要用迭代器,但不能重复使用,以免无法从初始位置开始
Iterator<Point> iterator = points.iterator();
if(iterator.hasNext())
{
endPoint = iterator.next();
if(endPoint==pointOnHull&&iterator.hasNext())
{
endPoint = iterator.next();
}
}
for(Point p:points)
{
//计算向量
v1X = endPoint.x()-pointOnHull.x();
v1Y = endPoint.y()-pointOnHull.y();
v2X = p.x()-pointOnHull.x();
v2Y = p.y()-pointOnHull.y();
//计算叉积
double crossProduct = v1X*v2Y - v2X*v1Y;
//叉积大于0,则p位于当前线段左侧,则更新endpoint
if(crossProduct>0)
{
endPoint = p;
//如果出现了左侧点,则pointsOnHull集合作废
isSet = false;
pointsOnHull.clear();
}
//这里需要判断p是否与另外两点重合。若重合,则忽略此情况
else if(p==endPoint||p==pointOnHull)
{
}
//此种情况说明三点共线。此时需要待定。先将共线的点加入集合pointsOnHull
else if(crossProduct==0)
{
pointsOnHull.add(endPoint);
pointsOnHull.add(p);
isSet = true;
}
}
pastPoint = pointOnHull;
pointOnHull = endPoint;
}
//最终,endPoint会回到最初的left点,此时完成了凸包的求解。
return convexHullPoints;
}
git pull拉取远程仓库信息时报错
- 问题:实验过程3.2的submitting部分,使用git pull拉取远程仓库信息时报错。
- 解决方法:这是“尝试合并两个没有共同祖先的分支历史”的错误。在 Git 2.9 之后的版本,Git 默认不允许这样的合并,以防止意外地合并不相关的历史。
出现这种情况的原因是我在创建远程仓库后直接在本地进行项目的编写,并保存在本地仓库中,这导致我的本地仓库与远程仓库没有相关的历史记录。在git pull命令后添加–allow-unrelated-histories合并不相关的历史,成功解决。
这是出现问题之后的补救方法,但这样做有可能会导致非法的合并操作,带来麻烦。
最好的办法还是在最开始,在创建远程仓库后就将本地的仓库与之绑定。
具体步骤在我之前写的博客Java编程与测试基础中的“如何创建远程仓库并关联到本地仓库”部分有详细介绍。
Lab-2
对集合类遍历时删除元素
- 问题:在ConcreteEdgeGraph类中测试删除某些边时,抛出异常ConcurrentModificationException()
- 解决方法:ConcurrentModificationException 是 Java 中的一个运行时异常,它表示在对集合进行迭代的过程中,由于在迭代过程中修改了集合的结构,导致迭代器抛出异常。
将增强型for循坏改为使用迭代器遍历并使用迭代器的remove()方法后,通过测试。
@Override public int set(L source, L target, int weight) {
//权重非0
//这里不用考虑小于0的情况,交给Edge类的checkRep完成
if(weight != 0)
{
Edge<L> e = new Edge<L>(source, target, weight);
//先判断是需要增加边还是修改边
//该边不存在,则添加
if(!this.edges.contains(e))
{
edges.add(e);
//边不存在,意味着顶点可能不存在,则需检验顶点
if(!vertices.contains(source)) this.vertices.add(source);
if(!vertices.contains(target)) this.vertices.add(target);
checkRep();
return 0;
}
//否则,该边存在,需要修改该边的权重
//但由于Edge类型不可变,所以需要先删除该边,然后再添加
else
{
//要返回的先前权值
int past_weight = 0;
Iterator<Edge<L>> iterator = edges.iterator();
//使用迭代器遍历edges,找到对应边删除
while(iterator.hasNext())
{
Edge<L> p = iterator.next();
if(p.equals(e))
{
past_weight = p.getWeight();
iterator.remove();
}
}
//添加该边
edges.add(e);
checkRep();
return past_weight;
}
}
//否则,进行删除操作
else
{
Edge<L> e = new Edge<L>(source, target, 1);
//要返回的先前权值
int past_weight = 0;
Iterator<Edge<L>> iterator = edges.iterator();
//使用迭代器遍历edges,找到对应边删除
while(iterator.hasNext())
{
Edge<L> p = iterator.next();
if(p.equals(e))
{
past_weight = p.getWeight();
iterator.remove();
}
}
checkRep();
return past_weight;
}
}
其实这个问题老师在课上提过,但在实验过程中我误以为使用增强型for循环与使用迭代器遍历的效果是一样的,但结果并非如此。为了保证遍历过程中删除操作正确进行,还是要使用迭代器进行遍历。具体解释参考我的另一篇博客数据类型与类型检验的“复杂数据类型与迭代器”部分。
checkRep()不通过的一个隐晦问题
- 问题:在实现ConcreteVerticesGraph类之后,测试时出现了断言不通过的异常。
- 解决方法:这是因为我在checkRep中使用了断言。通过对报错的分析,我定位到了set()方法。在这个方法中,我先加入了边再加入了顶点,因为在每个方法return之前都调用了checkRep,所以这个过程中“边被加入而顶点未被加入”的节点会被checkRep判断为违背了表示不变性。将其改为先加顶点再加边,成功通过。
该ADT的checkRep()如下:
private void checkRep() {
Set<L> labels = new HashSet<>();
for (Vertex<L> v : vertices) {
// 检查顶点标签是否唯一
assert !labels.contains(v.getLabel());
labels.add(v.getLabel());
// 检查顶点的出边集合是否符合要求
for (L target : v.getTargets()) {
// 检查边的权重是否大于 0
assert v.getWeight(target) > 0;
}
}
for (Vertex<L> v : vertices) {
// 检查出边集的顶点是否被包含在vertice中
for (L target : v.getTargets()) {
assert labels.contains(target);
}
}
}
注意最后部分的for循环,它检查了检查出边集的顶点是否被包含在vertice中。
在最开始的set()方法中,我先加入边再加入顶点。对整体的操作而言,这是没有任何问题的。但实际上,由于checkRep在任何方法的return前都对RI进行校验,在“加入边而未加入顶点”的时间结点上,checkRep就不会通过!
这是一个非常隐晦的问题,耗费了我大量时间来寻找。因为它本身并不是逻辑错误,而是处理顺序上的异常。所以,在使用checkRep校验RI时,一定要注意操作的中间过程是否违反了RI!如果可以通过一些手段更改的话,那么能避免就避免;如果实在无法避免,那么就只能稍稍在某些方法返回前放弃checkRep,以避免断言报错。
这个问题其实还比较普遍,因为在Lab3-中也遇到了这样的问题,不过实验指导中给出了提示:
Lab-3
构造方法的空指针Null异常
- 问题:设计MultiIntervalSet的intervals()方法时,先设计为如果标签不存在返回null,发现报错
- 解决方法:因为intervals返回的是一个IntervalSet对象,而且我使用了防御式拷贝,这就导致如果不存在导致对象为null的话,会使拷贝时的构造函数尝试访问null的字段。所以需要添加构造函数,使用增强型for循环遍历以提取信息,并将intervals()方法改为“标签不存在则返回一个空的IntervalSet对象”,成功解决。
/**
* 从当前对象中获取与某个标签所关联的所有时间段
* @param label 标签
* @return 一个IntervalSet<Integer>类型的时间段集合,
* 其中的时间段按开始时间从小到大的次序排列,标签从0开始。
* 若没有该标签对应的时间段,返回空对象。
*/
public IntervalSet<Integer> intervals(L label);
...
//下面是实现类中的方法
@Override
public IntervalSet<Integer> intervals(L label) {
checkRep();
return new CommonIntervalSet<Integer>(MultiIntervalMap.get(label));
}
//使用的构造函数
public CommonMultiIntervalSet(IntervalSet<L> initial)
{
for(L label:initial.labels())
{
long start = initial.start(label);
long end = initial.end(label);
this.insert(start, end, label);
}
}
测试的方法与checkRep相关
- 问题:在设计委派关系时,发现委派的方法checkOverlap()会参与到checkRep中,导致在测试时无法测试到违背Rep的情况。
- 解决方法:更换思路,将测试该函数的返回值改为测试是否出现断言错误。如果出现断言错误则说明返回了true,从而涵盖了所有情况,成功解决。
//下面这段是测试代码
//当没有重叠时是否返回false
//当重叠时是否会触发断言错误,即返回true
@Test
public void testCheckOverlap() {
ProcessIntervalSet<String> test = new ProcessIntervalSet<String>();
test.insert(0, 1, "A");
test.insert(2, 3, "B");
assertFalse(test.checkOverlap());
assertThrows(AssertionError.class, () -> {
test.insert(2, 4, "C");
});
}
//下面这段是checkRep
// checkRep
// 时间段的RI已在Period内部保证。
private void checkRep()
{
for(L label:MultiIntervalMap.keySet())
{
IntervalSet<Integer> periods = MultiIntervalMap.get(label);
int periodCounts = periods.labels().size();
for(int i = 1;i<periodCounts;i++)
{
//验证越小的标签,起始时间越小
assert periods.start(i) > periods.start(i-1);
//验证没有重叠的时间段
assert periods.start(i) >= periods.end(i-1);
}
}
//还要验证不可重叠
assert !checkOverlap();
}
具体解释一下,这里的checkOverlap是检查是否有重叠的时间段,但RI本身要求不能有重叠。这里可以看到checkRep使用了checkOvelap。所以当出现重叠时间段时,会违反RI,使得checkOverlap()为true,无法通过checkRep(),导致断言错误,从而也就无法验证checkOverlap返回true。
实际上这里可以转换思路,因为此处checkOverlap返回true与抛出断言错误是等价的,所以改“验证checkOverlap的返回值”为“验证是否出现断言错误”,这样就可以完成测试了。
正则表达式识别多个独立部分
- 问题:在3.7部分分析语法时,无法通过正则表达式直接对其进行提取。
- 解决方法:原因在于无法将Employee,Roster和Period三大部分进行识别。转换思路,先进行字符的处理,将三大部分提取出来再处理。
在当时我的解决办法是先读文件,将内容保存至一个String[]数组中,通过对每一行检验,分别得到这三大部分的内容,然后再使用正则表达式处理。这样还能保证与这三大部分的出现顺序无关。
其实现在看来,这里还可以直接使用正则表达式做预处理。在对整体进行合法性的识别的基础上,我们完全可以使用部分匹配的方式将三部分提取出来:使用.find()方法,实现对整个字符串某一部分的匹配。这样分别对这三部分构造正则表达式,对整体字符串识别三次便可以取出这三部分的内容,然后再对这三部分的内容细分处理即可。
这里举个简单的例子:
String text = "Employee{Zhang{123}Lisi{23}WangWu{888}}";
String legalRegex = "Employee\\{([a-zA-Z]+\\{(\\d+)\\})+\\}";
Pattern legalPattern = Pattern.compile(legalRegex);
Matcher legalMatcher = legalPattern.matcher(text);
if(legalMatcher.matches())
{
System.out.println("匹配成功!");
}
//去除第一部分
String regex1 = "Employee\\{(([a-zA-Z]+\\{\\d+\\})+)\\}";
Pattern pattern1 = Pattern.compile(regex1);
Matcher m1 = pattern1.matcher(text);
String text1 = null;
if(m1.matches())
{
text1 = m1.group(1);
System.out.println(text1);
}
//开始匹配
String regex = "([a-zA-Z]+)\\{(\\d+)\\}";
Pattern pattern = Pattern.compile(regex);
Matcher m = pattern.matcher(text1);
while(m.find())
{
System.out.println("名字: "+m.group(1));
System.out.println("数字: "+m.group(2));
}
这里先验证整体的合法性,然后使用正则表达式“扣掉”Employee{}的外部,得到内部内容,然后再使用.find()对每个内部内容进行识别即可。
需要重复提取文件内容的情况
- 问题:在3.7部分读取文件时,需要反复读取,但是Java的BufferReader在读完一次后便无法通过正常途径回到文件开头,导致无法反复读取。
- 解决方法:放弃使用“反复读取文件”的方式,转而采取“读取一次文件,把文件内容存到变量中”的方式。在这里将文件内容按照行存到String[]类型,即字符串数组中。然后再对这个字符串数组进行处理。
private static String[] readFileContent(String filename) {
try {
FileReader fileReader = new FileReader(filename);
BufferedReader bufferedReader = new BufferedReader(fileReader);
StringBuilder contentBuilder = new StringBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
contentBuilder.append(line).append("\n");
}
bufferedReader.close();
// 将文件内容分割成行
String[] lines = contentBuilder.toString().split("\n");
return lines;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
按照原有的思路,我需要从文件中提取三大部分,因为它们的顺序是不确定的,所以需要反复读取识别。
但我在尝试反复读取的过程中,发现读完一次文件后,文件指针便到达了末尾,我尝试了各种方法试图将其移到开头,发现均失败。
于是我转换思路,既然只能读一次,那么为何不将这一次读取的结果保存在String数组中呢?这样既可以反复处理读取的内容,还能保留原有的信息。事实证明,这一思路是可行的。
整体总结
这三次实验让我初步接触了Java编程,体会了OOP思想。虽然这三次实验体量不大,但却涉及到了课程内容的方方面面,让我在实践中巩固了所学知识,并体会到了学以致用,构造软件的成就感。
总结来看,我认为自己在以下方面还有欠缺:
- ADT的设计:对于spec,AF,RI等设计还不够熟练,后续在使用过程中常常遇到不够完善的地方,导致对代码的反复修改。
- 各接口、各类之间的组织:对所学的几大设计模式缺少实践,尤其是Lab-3,对整体接口和类、以及委派关系的实现耗费了比较多的时间。我认为将各个接口和类良好的组织起来,使其可复用和可维护是比较困难的。
- 测试优先的编程:之前我经常习惯于先写代码再测试,在这三次实验中我尝试先写测试再写代码,这在Lab-1和Lab-2中实现的比较顺利。但是在Lab-3,由于工作量比较大,所写的测试往往不再适配多次更改的代码(比如中途对不合理的spec进行修改),导致无法顺利进行下去。我认为原因在于自己在最初的设计阶段做的不够好,导致后面遇到许多麻烦,所以还是应当多锻炼自己的spec设计能力和对项目整体框架的把握。
实验过程中,我遇到了许多问题,上面选取了我认为比较有价值的几个问题,并给出了我自己的解决方法,希望能够给遇到这些问题的朋友们参考,也算作是我对软件构造这门课程实验的自我反省。
在这门课的实验中,我通过实践体会到了软件构造的种种困难,但也锻炼了我的编程能力,积累了项目开发的经验。我认为这门课是非常有价值的,希望我能够带着这门课所学的知识,向更高层次的程序迈进!