【软件构造】实验笔记(一)Lab1-Fundamental Java Programming and Testing

一、前言

《软件构造》课程是我校根据MIT、CMU等计算机领域名校的相关课程近年来开展的软件开发相关的课程。课程的实验和课件都很大程度上参考了上述学校。
本笔记对在课程实验练习进行中遇到的问题进行总结,方便以后查阅。
实验一主要是考察java编程基础,由于大一学的比较仓促,很多东西都有所遗忘,所以本篇笔记也以java编程与相关算法为主。

二、实验要求

1、Magic Squares (MIT)

要求1

A magic square of order n is an arrangement of n×n numbers, usually distinct integers, in a square, such that the n numbers in all rows, all columns, and both diagonals sum to the same constant (see Wikipedia: Magic Square).
【软件构造】实验笔记(一)Lab1-Fundamental Java Programming and Testing_第1张图片
You are to write a Java program (MagicSquare.java) for checking the row/column/diagonal values of a matrix and then judging whether it is a magic squares.
We give you five text files: 1.txt, 2.txt, …, 5.txt. Download them from https://github.com/rainywang/Spring2019_HITCS_SC_Lab1/tree/master/P1 and add them into your project directory \src\P1\txt;
For each file: open the file, and check that all rows indeed sum to the same constant.
Check that the columns and the diagonal also sum to the same constant.
Return a boolean result indicating whether the input is a magic square or not.
函数规约:boolean isLegalMagicSquare(String fileName)
在main()函数中调用五次isLegalMagicSquare()函数,将5个文本文件名分别作为参数输入进去,看其是否得到正确的输出(true, false)。
需要能够处理输入文件的各种特殊情况,例如:文件中的数据不符合Magic Square的定义(行列数不相等、并非矩阵等)、矩阵中的某些数字并非正整数、数字之间并非使用\t分割等。若遇到这些情况,终止程序执行(isLegalMagicSquare函数返回false),并在控制台输出错误提示信息。

Some Hints

Copy all five text files to the \src\P1\txt\ directory of your project. You can also use absolute paths to the files (c:\somedir\1.txt on Windows or /Users/myuser/1.txt on Mac). However, it is better to use the relative paths of these files.
You will need to handle or re-throw IOException.
Read the files line by line. Use … = myLine.split("\t"); to break apart each line at the tab character, producing an array of String (String[]), each containing one value. Consult the Java API reference for String.split).
Finally, use … = Integer.valueOf(substring); to transform each string value into an integer value.

要求2

阅读以下代码,将其加入你的MagicSquare类中作为一个静态函数,并试着在main()中测试它。

public static boolean generateMagicSquare(int n) {

		int magic[][] = new int[n][n];
		int row = 0, col = n / 2, i, j, square = n * n;

		for (i = 1; i <= square; i++) {
			magic[row][col] = i;
			if (i % n == 0)
				row++;
			else {
				if (row == 0)
					row = n - 1;
				else
					row--;
				if (col == (n - 1))
					col = 0;
				else
					col++;
			}
		}

		for (i = 0; i < n; i++) {
			for (j = 0; j < n; j++)
				System.out.print(magic[i][j] + "\t");
			System.out.println();
		}

return true;
}

为该函数绘制程序流程图,并解释它如何根据输入的参数(奇数n)生成一个n×n的Magic Square。据此为上述代码添加中文注释。
如果输入的n为偶数,函数运行之后在控制台产生以下输出:

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 12
	at MagicSquare.generateMagicSquare(MagicSquare.java:17)
	at MagicSquare.main(MagicSquare.java:121)

如果输入的n为负数,函数运行之后在控制台产生以下输出:

Exception in thread "main" java.lang.NegativeArraySizeException
	at MagicSquare.generateMagicSquare(MagicSquare.java:11)
	at MagicSquare.main(MagicSquare.java:121)

请查阅JDK了解上述异常的含义,并分析该函数为何会产生这些异常(注:我们将在Lab4中训练异常处理机制)。
对该函数做扩展:(1) 将产生的magic square写入文件\src\P1\txt\6.txt中;(2) 当输入的n不合法时(n为偶数、n为负数等),不要该函数抛出异常并非法退出,而是提示错误并“优雅的”退出——函数输出false结束。
利用你前面已经写好的isLegalMagicSquare()函数,在main()函数判断该函数新生成的文本文件6.txt是否符合magic square的定义。

2、Turtle Graphics (MIT)

请阅读http://web.mit.edu/6.031/www/sp18/psets/ps0/,遵循该页面内的要求完成编程任务。
在Problem 1: Clone and import中你无法连接MIT的didit服务器,请从https://github.com/rainywang/Spring2019_HITCS_SC_Lab1/tree/master/P2获取代码。
忽略Problem 2: Collaboration policy。
在Problem 4: Commit and push your work so far步骤的第g步,忽略页面中涉及Athena和didit的内容,请使用git指令将代码push到你的GitHub仓库中。
在页面最后的Submitting步骤中,请同样将你的代码push到你的GitHub Lab1仓库上。
其他步骤请遵循MIT作业页面的要求。

3、Social Network (CMU)

本作业来自于CMU 15-214软件构造课。

Implement and test a FriendshipGraph class that represents friendships in a social network and can compute the distance between two people in the graph. An auxiliary class Person is also required to be implemented.
You should model the social network as an undirected graph where each person is connected to zero or more people, but your underlying graph implementation should be directed. 注:本问题拟刻画的社交网络是无向图,但你的类设计要能够支持未来扩展到有向图。正因为此,如果要在两个Person对象A和B之间增加一条社交关系,那么需要同时调用addEdge(A,B)和addEdge(B,A)两条语句。
For example, suppose you have the following social network:
【软件构造】实验笔记(一)Lab1-Fundamental Java Programming and Testing_第2张图片
Your solution must work with the following client implementation.

  1. FriendshipGraph graph = new FriendshipGraph();
  2. Person rachel = new Person(“Rachel”);
  3. Person ross = new Person(“Ross”);
  4. Person ben = new Person(“Ben”);
  5. Person kramer = new Person(“Kramer”);
  6. graph.addVertex(rachel);
  7. graph.addVertex(ross);
  8. graph.addVertex(ben);
  9. graph.addVertex(kramer);
  10. graph.addEdge(rachel, ross);
  11. graph.addEdge(ross, rachel);
  12. graph.addEdge(ross, ben);
  13. graph.addEdge(ben, ross);
  14. System.out.println(graph.getDistance(rachel, ross));
    //should print 1
  15. System.out.println(graph.getDistance(rachel, ben));
    //should print 2
  16. System.out.println(graph.getDistance(rachel, rachel));
    //should print 0
  17. System.out.println(graph.getDistance(rachel, kramer));
    //should print -1

Your solution should work with the client code above. The getDistance method should take two people (as Person) as arguments and return the shortest distance (an int) between the people, or -1 if the two people are not connected (or in other words, there are no any paths that could reach the second people from the first one).
Your graph implementation should be reasonably scalable. We will test your graph with several hundred or thousand vertices and edges.
Use proper access modifiers (public, private, etc.) for your fields and methods. If a field/method can be private, it should be private.
Do not use static fields or methods except for the main method(s) and constant
Follow the Java code conventions, especially for naming and commenting. Hint: use Ctrl + Shift + F to auto-format your code!
Add short descriptive comments (/** … */) to all public methods.

Additional hints/assumptions

For your implementation of getDistance, you may want to review breadth-first search.
You may use the standard Java libraries, including classes from java.util, but no third-party libraries.
You may assume that each person has a unique name.
You may handle incorrect inputs however you want (printing to standard out/error, silently failing, crashing, throwing a special exception, etc.)
You should write additional samples to test your graph, similar to our main method.
To print something to standard out, use System.out.println. For example:
System.out.println(“DON’T PANIC”);
You should also write JUnit test code to test the methods addVertex(), addEdge(), and getDistance() of the class FriendshipGraph. All the test cases should b e included in FriendshipGraphTest.java in the directory \test\P3. Test cases should be sufficient enough.
如果将上述代码的第10行注释掉(意即rachel和ross之间只存在单向的社交关系ross->rachel),请人工判断第14-17行的代码应输出什么结果?让程序执行,看其实际输出结果是否与你的期望一致?
如果将第3行引号中的“Ross”替换为“Rachel”,你的程序会发生什么?这其实违反了“Each person has a unique name”的约束条件。修改你的FriendshipGraph类和Person类,使该约束能够始终被满足(意即:一旦该条件被违反,提示出错并结束程序运行)。

三、正文

1、Magic Squares (MIT)

具体实现难度不大,就是处理各种异常情况稍微麻烦点,如果想把代码写的优雅,减少不必要的变量也需要仔细考虑。

相对路径

遇到的第一个问题就是矩阵文本文件找不到,我采用的相对路径进行查找,默认以为就是java文件所在的路径是其相对的路径,后来经过查资料和测试发现,应该是项目的根目录,所以对于下面这个路径,应该是 String path = “src/P1/txt/”+fileName;
【软件构造】实验笔记(一)Lab1-Fundamental Java Programming and Testing_第3张图片
但是idea在编译设置时有对工作目录的设置,如果设置成别的相对路径也就变了。
【软件构造】实验笔记(一)Lab1-Fundamental Java Programming and Testing_第4张图片
另外注意两个等价写法,/与\\都可以,然后就是src与./src都可以,但是不可以/src。

数组初始化

java可以通过new来初始化数组,支持动态大小,并且默认初始化为0。

文件读写

java文件读写的方法不止一种,可以用各种io相关的类,也可以用流解决。相对而言使用java.io.*引用的包比较少,所以采用了这种方式。

import java.io.*;

public class File{
	public static void ReadFile(String path){
        try (FileReader reader = new FileReader(path);
             BufferedReader br = new BufferedReader(reader)
        ) {
            String line;
            while ((line = br.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
	}
	public static void WriteFile(String path, String text){
	        try {
            File writeName = new File(path);
            writeName.createNewFile();
            try (FileWriter writer = new FileWriter(writeName);
                 BufferedWriter out = new BufferedWriter(writer)
            ) {
                out.write(text + "\r\n");
                out.flush();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
	}
}
static函数调用

static函数直接调用的函数必须也是static,难道为了main函数只能把所有函数声明为static吗?其实不用,实例化一个对象然后调用相应函数即可。

2、Turtle Graphics (MIT)

具体要求参照MIT的链接即可。这里还是只把遇到的问题做一下记录。
这个部分卡的时间比较长,一方面是英文的要求看起来有些吃力,另一方面涉及到一些算法而非语法的地方相对薄弱一点。

Math.atan2

/**
* 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

这是其中一个函数的编写要求,下面是我的实现代码:

    public static double calculateBearingToPoint(double currentBearing, int currentX, int currentY,
                                                 int targetX, int targetY) {
        double angle = Math.atan2(targetX - currentX, targetY - currentY) * 180 / Math.PI - currentBearing;
        if (angle < 0)
            angle += 360;
        return  angle;
    }

这个函数之前没有用过,后来发现在C++也有类似的函数。

double atan2(double y, double x)//atan2() 方法用于将矩形坐标 (x, y) 转换成极坐标 (r, theta),返回所得角 theta。该方法通过计算 y/x 的反正切值来计算相角 theta,范围为从 -pi 到 pi。

其实就是计算夹角,上面的解释很清楚,至于这个函数画个图就能大概推测公式。

java内置数据结构

java里有很多自带的数据结构,类似C++中的STL。而很多数据结构只是抽象类,例如map、Set,是无法实例化的,所以实际操作时需要实例化为具体类例如HashSet以及必要时强转。
另外,要注意add方法事实上是添加引用,而非具体内容。

凸包问题

这是卡了我时间最长的问题,原题如下:

   /**
     * 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) {
         	//todo 
         }

凸包问题是很多算法竞赛的题目,方法也很多,性能各不相同。我从网上找了半天,代码都比较复杂,让人望而止步,而且个人算法比较薄弱,看起来也有点吃力。最终找到了一个比较好的实现。

// Java program to find convex hull of a set of points. Refer 
// https://www.geeksforgeeks.org/orientation-3-ordered-points/ 
// for explanation of orientation() 
import java.util.*; 

class Point 
{ 
	int x, y; 
	Point(int x, int y){ 
		this.x=x; 
		this.y=y; 
	} 
} 

class GFG { 
	
	// To find orientation of ordered triplet (p, q, r). 
	// The function returns following values 
	// 0 --> p, q and r are colinear 
	// 1 --> Clockwise 
	// 2 --> Counterclockwise 
	public static int orientation(Point p, Point q, Point r) 
	{ 
		int val = (q.y - p.y) * (r.x - q.x) - 
				(q.x - p.x) * (r.y - q.y); 
	
		if (val == 0) return 0; // collinear 
		return (val > 0)? 1: 2; // clock or counterclock wise 
	} 
	
	// Prints convex hull of a set of n points. 
	public static void convexHull(Point points[], int n) 
	{ 
		// There must be at least 3 points 
		if (n < 3) return; 
	
		// Initialize Result 
		Vector<Point> hull = new Vector<Point>(); 
	
		// Find the leftmost point 
		int l = 0; 
		for (int i = 1; i < n; i++) 
			if (points[i].x < points[l].x) 
				l = i; 
	
		// Start from leftmost point, keep moving 
		// counterclockwise until reach the start point 
		// again. This loop runs O(h) times where h is 
		// number of points in result or output. 
		int p = l, q; 
		do
		{ 
			// Add current point to result 
			hull.add(points[p]); 
	
			// Search for a point 'q' such that 
			// orientation(p, x, q) is counterclockwise 
			// for all points 'x'. The idea is to keep 
			// track of last visited most counterclock- 
			// wise point in q. If any point 'i' is more 
			// counterclock-wise than q, then update q. 
			q = (p + 1) % (n -1); 
			
			for (int i = 0; i < n; i++) 
			{ 
			// If i is more counterclockwise than 
			// current q, then update q 
			if (orientation(points[p], points[i], points[q]) 
												== 2) 
				q = i; 
			} 
	
			// Now q is the most counterclockwise with 
			// respect to p. Set p as q for next iteration, 
			// so that q is added to result 'hull' 
			p = q; 
	
		} while (p != l); // While we don't come to first 
						// point 
	
		// Print Result 
		for (Point temp : hull) 
			System.out.println("(" + temp.x + ", " + 
								temp.y + ")"); 
	} 
	
	/* Driver program to test above function */
	public static void main(String[] args) 
	{ 

		Point points[] = new Point[7]; 
		points[0]=new Point(0, 3); 
		points[1]=new Point(2, 3); 
		points[2]=new Point(1, 1); 
		points[3]=new Point(2, 1); 
		points[4]=new Point(3, 0); 
		points[5]=new Point(0, 0); 
		points[6]=new Point(3, 3); 
		
		int n = points.length; 
		convexHull(points, n); 
		
	} 
} 
	
// This code is contributed by Arnav Kr. Mandal. 

代码短小精悍,具体逻辑参考注释。

规则图形边数计算

给出了每个角的大小可以根据三角形内角和快速求解,这里不做赘述。

艺术设计

最后题目要求通过turtle自行设计一个图形,参考了网上一些方法,下面是我的结果:
【软件构造】实验笔记(一)Lab1-Fundamental Java Programming and Testing_第5张图片
代码如下:

    public static void drawPersonalArt(Turtle turtle) {
        for (int i = 0 ; i < 1500; i++) {
            turtle.forward(i/2);
            switch ((i/15) % 10) {
                case 0:turtle.color(PenColor.BLACK);break;
                case 1:turtle.color(PenColor.GRAY);break;
                case 2:turtle.color(PenColor.RED);break;
                case 3:turtle.color(PenColor.PINK);break;
                case 4:turtle.color(PenColor.ORANGE);break;
                case 5:turtle.color(PenColor.YELLOW);break;
                case 6:turtle.color(PenColor.GREEN);break;
                case 7:turtle.color(PenColor.CYAN);break;
                case 8:turtle.color(PenColor.BLUE);break;
                case 9:turtle.color(PenColor.MAGENTA);break;
            }
            turtle.turn(91);
        }
3、Social Network (CMU)
图与最短路径

这个部分考察的是java实现数据结构相关的知识。因为之前学数据结构是C++表示,加上有一定时间了,这次用java并不是特别顺手。
简单的说就是考察一个无向图,找单源最短路径,由于边权值均为1,所以事实上不需要什么迪彻斯特或者弗洛伊德,仅仅靠广度优先搜索即可解决。感觉自己的代码写麻烦了,希望有人可以指正一下。这也说明我数据结构学的并不深入,遗忘了很多。
另外助教提醒还可以采用双向BFS来减小搜索面积,有时间可以研究下。

/**
     * Using BFS to get the distance of the two people.
     *
     * @param src the name of the source person.
     * @param dest the name of the destination person.
     * @return the distance between the two people.
     */
    public int getDistance(Person src, Person dest){
        if (src.getName().equals(dest.getName()))
            return 0;
        Map<String, Boolean> visited = new HashMap<>();
        Map<String, Integer> level = new HashMap<>();
        for (VertexNode person: vertex)
            visited.put(person.person.getName(), false);
        visited.put(src.getName(), true);
        level.put(src.getName(), 0);
        Queue<VertexNode> queue = new LinkedBlockingQueue<>();
        queue.add(getVertexByName(src.getName()));
        while (!queue.isEmpty()){
            VertexNode vertex = queue.poll();
            EdgeNode temp = vertex.firstEdge;
            while (temp != null){
                if (!visited.get(temp.person.getName())){
                    if (temp.person.getName().equals(dest.getName()))
                        return level.get(vertex.person.getName()) + 1;
                    else{
                        visited.put(temp.person.getName(), true);
                        level.put(temp.person.getName(), level.get(vertex.person.getName()) + 1);
                        queue.add(getVertexByName(temp.person.getName()));
                    }
                }
                temp = temp.next;
            }
        }
        return -1;
    }

这里不得不提由于最开始对要求的参数类型没怎么注意,绕了很多弯路,比如最开始src和dest都采用String,而题目要求是Person。

JUnit

做这个实验之前我并不知道什么事JUnit,当然现在了解也并不多,记录一点了解到的东西。
这个库用来做Java程序的测试,将真实值与预计值比较,如果不相等,或者在误差内不相等,那么便抛出异常终止程序,类似

throw new RuntimeException("Exception message");

我用的比较多是下面这个函数:

assertEquals(1, graph.getDistance(rachel, ross));

另外一般使用的时候参照以下规范:

import static org.junit.Assert.*;

import org.junit.Test;

public class ClassNameTest{
    /**
     * Tests that assertions are enabled.
     */
    @Test(expected = AssertionError.class)
    public void testAssertionsEnabled() { assert false; }
    
	/**
     * Tests MethodName.
     */
    @Test
    public void MethodNameTest(){
		//Code
        assertEquals(RealValue, ExpectedValue);
    }
}
String

用一些像Map之类的内置类,总在想String做键值会不会值相等但是却不相等(即equls满足但是双等号不满足),后来经过测试加上回忆了一下String如果相等指向的是同一块内存,所以map.get(“value”)能够正常取到。如果这个String类型值变了则指向别的内存,但还是有点不清楚的地方,等我比较比较几种情况再得出更普遍的结论。
总之,在java中一定要特别注意双等号与equals的区别。
下面是我当时测试时用的代码:

HashMap<String, String> map = new HashMap<>();
String name = "jack";
map.put(name, "jack");
String name_ = "jack";
System.out.printf(map.get(name_));

3、Social Network (CMU)

这个部分考察的是程序功能模块的编写,不像第二题对算法要求较高。说白了考的是字符串的处理,从字符串中提取信息。以爬取的推特内容为数据,根据@来分析用户用户之间的关系,构建社交网络。题不大,但是对于数据分析的启发不小。
另外,这个题比较麻烦的一点在于编写JUnit测试程序,以及最后要求通过一些证据来扩大社交网络。

java.time.Instant

这是一个时间数据类型,内置after和before进行比较。

map根据value排序
public static void valueUpSort() {
    // 默认情况,TreeMap按key升序排序
    Map<String, Integer> map = new TreeMap<String, Integer>();
    map.put("acb1", 5);
    map.put("bac1", 3);
    map.put("bca1", 20);
    map.put("cab1", 80);
    map.put("cba1", 1);
    map.put("abc1", 10);
    map.put("abc2", 12);
    
    // 升序比较器
    Comparator<Map.Entry<String, Integer>> valueComparator = new Comparator<Map.Entry<String,Integer>>() {
        @Override
        public int compare(Entry<String, Integer> o1,
                Entry<String, Integer> o2) {
            // TODO Auto-generated method stub
            return o1.getValue()-o2.getValue();
        }
    };

    // map转换成list进行排序
    List<Map.Entry<String, Integer>> list = new ArrayList<Map.Entry<String,Integer>>(map.entrySet());

    // 排序
    Collections.sort(list,valueComparator);

    // 默认情况下,TreeMap对key进行升序排序
    System.out.println("------------map按照value升序排序--------------------");
    for (Map.Entry<String, Integer> entry : list) {
        System.out.println(entry.getKey() + ":" + entry.getValue());
    }
}
entrySet

Map.Entry是Map声明的一个内部接口,此接口为泛型,定义为Entry。它表示Map中的一个实体(一个key-value对)。接口中有getKey(),getValue方法。

获取@的人

最开始这个地方写错了,获取完一个就直接返回了,事实上一条推特中可能会@很多人,所以要删掉这个@并继续找还有没有@。

删除字符串某个字符

content =content.substring(0, signIndex) + content.substring(signIndex + 1);

测试程序

编写测试程序相对用的时间比较长,要注意测试时要涵盖各种情况,例如一个人关注一个人,一个人关注多个人等等,这样容易检验出程序设计时没有考虑到的地方。

get smarter

Problem 4: Get smarter
In this problem, you will implement one additional kind of evidence in guessFollowsGraph(). Note that we are taking a broad view of “influence” here, and even Twitter-following is not a ground truth for influence, only an approximation. It’s possible to read Twitter without explicitly following anybody. It’s also possible to be influenced by somebody through other media (email, chat, real life) while producing evidence of the influence on twitter.
Here are some ideas for evidence of following. Feel free to experiment with your own.
Common hashtags. People who use the same hashtags in their tweets (e.g. #mit) may mutually influence each other. People who share a hashtag that isn’t otherwise popular in the dataset, or people who share multiple hashtags, may be even stronger evidence.
Triadic closure. In this context, triadic closure means that if a strong tie (mutual following relationship) exists between a pair A,B and a pair B,C, then some kind of tie probably exists between A and C – either A follows C, or C follows A, or both.
Awareness. If A follows B and B follows C, and B retweets a tweet made by C, then A sees the retweet and is influenced by C.
Keep in mind that whatever additional evidence you implement, your guessFollowsGraph() must still obey the spec. To test your specific implementation, make sure you put test cases in your own MySocialNetworkTest class rather than the SocialNetworkTest class that we will run against staff implementations. Your work on this problem will be judged by the clarity of the code you wrote to implement it and the test cases you wrote to test it.

本来想用图来做,从是否可达上来分析传递的关注关系,甚至求出距离,但是后来感觉太麻烦就算了。最后采取的方案是上面提到的最后一条,“A会因为看到B转发C的推送而受到C的影响”。
当然我写的很麻烦,复杂度达到了n的立方,在建立好关注网络后再重新遍历推送,如果里面包含“@XXX:” 也就是转了某人的推送,那么关注他的人也会建立与“XXX”的社交关系。最后在测试程序里写个例子体现下就大功告成了。

四、参考资料

https://blog.csdn.net/nickwong_/article/details/51502969
https://www.geeksforgeeks.org/convex-hull-set-1-jarviss-algorithm-or-wrapping/
https://www.cnblogs.com/rrttp/p/7922202.html
https://blog.csdn.net/slh2016/article/details/53148681
https://www.cnblogs.com/shaohz2014/p/3667862.html
https://blog.csdn.net/yangyutong0506/article/details/78190245

你可能感兴趣的:(软件构造)