跳转至

第 3 章 测试

原作:Josh Hug,UC Berkeley CS61B Spring 2021 配套读本。
中文翻译版,仅供非商业学习;采用 CC BY-NC-SA 4.0 许可。
原始网站:https://joshhug.gitbooks.io/hug61b/content/



3.1 一种新的方式

中高级程序员最重要的能力之一,是判断自己的代码是否正确。本章将讨论如何编写测试来评估代码正确性。在这一过程中,还会介绍一种名为选择排序的排序算法。

一种新的方式

视频讲解

编写程序时,代码中可能存在错误。在课堂环境中,你通常通过用户交互、代码分析和自动评分器测试的某种组合来建立对代码正确性的信心。其中自动评分器往往最重要,尤其是因为它直接决定你能获得多少分。

当然,自动评分器并不是魔法。它只是教师编写的代码,本质上与你写的代码没有太大不同。在现实世界中,这些测试通常由程序员自己编写,而不是由某个像 Josh Hug 一样友善的第三方替你完成。

本章将探索如何编写自己的测试。目标是创建一个名为 Sort 的类,其中提供方法 sort(String[] x),对数组 x 中的字符串进行原地排序。

我们要采用一种全新的思考方式:先编写 testSort(),完成测试之后,才开始写真正的排序代码。

临时拼凑式测试

视频讲解

Sort.sort 编写测试并不复杂,只是比较繁琐。我们只需创建输入、调用 sort,再检查方法输出是否正确。如果不正确,就输出第一个不匹配的位置并终止测试。例如,可以创建下面的测试类:

public class TestSort {
    /** Tests the sort method of the Sort class. */
    public static void testSort() {
        String[] input = {"i", "have", "an", "egg"};
        String[] expected = {"an", "egg", "have", "i"};
        Sort.sort(input);
        for (int i = 0; i < input.length; i += 1) {
            if (!input[i].equals(expected[i])) {
                System.out.println("Mismatch in position " + i + ", expected: " + expected + ", but got: " + input[i] + ".");
                break;
            }
        }
    }

    public static void main(String[] args) {
        testSort();
    }
}

可以创建一个空白的 Sort.sort 方法来检验测试本身:

public class Sort {
    /** Sorts strings destructively. */
    public static void sort(String[] x) {        
    }
}

用这个空方法运行 testSort(),会得到:

Mismatch in position 0, expected: an, but got: i.

看到错误信息是好事,这说明测试确实能够发现错误。更有意思的是,我们现在给自己创造了一个小游戏:目标是修改 Sort.sort,直到错误信息消失。这在心理上是个小技巧,许多程序员会发现,这种自己创造的小谜题几乎令人上瘾。

它很像课程有自动评分器时的情况:你会沉迷于让评分器向你表达认可。现在,你也有能力为自己的代码创造一个裁判;只有正确完成代码,才能赢得它的肯定。

重要说明: 你可能会问:“为什么要遍历整个数组?为什么不直接使用 == 判断两个数组相等?”原因是,比较两个对象是否相等时,不能简单使用 ==== 比较的是内存盒中原样保存的比特。例如 input == expected 检查的是 inputexpected 是否保存相同地址,而不是两个数组中的值是否相同。因此,testSort 使用循环并输出第一个不匹配位置。也可以使用内置方法 java.util.Arrays.equals 替代循环。

上面的单个测试工作量还不算大,但若要编写一整套这种_临时拼凑式_测试,就必须写很多不同的循环和输出语句,会非常繁琐。下一节将看到 org.junit 库如何节省大量工作。

JUnit 测试

视频讲解

org.junit 库提供了许多辅助方法和功能,用于简化测试编写。例如,上面的临时测试可以替换为:

public static void testSort() {
    String[] input = {"i", "have", "an", "egg"};
    String[] expected = {"an", "egg", "have", "i"};
    Sort.sort(input);
    org.junit.Assert.assertArrayEquals(expected, input);
}

这段代码简单得多,完成的事情却几乎完全相同:如果数组不相等,它会指出第一个不匹配位置。例如,在什么都不做的 Sort.sort 上运行 testSort(),会得到:

Exception in thread "main" arrays first differed at element [0]; expected:<[an]> but was:<[i]>
    at org.junit.internal.ComparisonCriteria.arrayEquals(ComparisonCriteria.java:55)
    at org.junit.Assert.internalArrayEquals(Assert.java:532)
    ...

输出比之前的临时测试稍显难看,不过本章末尾会介绍如何让它更友好。

选择排序

视频讲解

在编写 Sort.sort 之前,需要先有一个排序算法。最简单的排序算法之一是“选择排序”。它包含三个步骤:

  • 找到最小元素。
  • 把它移动到最前面。
  • 对剩下的 N - 1 个元素继续执行选择排序,不再触碰已经放好的第一个元素。

例如,假设数组为 {6, 3, 7, 2, 8, 1}。其中最小元素是 1,因此把 1 移到最前面。一种自然办法是把 1 插到开头,再把其他数字整体后移,得到 {1, 6, 3, 7, 2, 8};但效率高得多的办法,是直接交换 1 与原来的第一个元素(这里是 6),得到 {1, 3, 7, 2, 8, 6}

接着对剩余数字重复相同过程。3, 7, 2, 8, 6 中最小的是 2,交换到剩余部分的开头后得到 {1, 2, 7, 3, 8, 6}。不断重复,依次得到 {1, 2, 3, 7, 8, 6}{1, 2, 3, 6, 8, 7},最终得到 {1, 2, 3, 6, 7, 8}

可以使用 2.4 节介绍的不变量,从数学上证明该排序算法对任意数组都正确,不过本书不展开证明。继续之前,建议自己写一个短数字数组并手动执行选择排序,确保真正理解它。

知道选择排序如何工作后,可以在空白的 Sort.sort 中先写几条简短注释,引导思考:

public class Sort {
    /** Sorts strings destructively. */
    public static void sort(String[] x) { 
           // find the smallest item
           // move it to the front
           // selection sort the rest (using recursion?)
    }
}

后续小节会逐步完成选择排序实现。我会故意采用学生可能使用的解题方式,因此过程中会有意犯几个错误。这些错误是好事,因为它们可以展示测试的价值。如果阅读时发现错误,不必担心,后面会逐一修正。

findSmallest

视频讲解

最自然的起点是编写一个寻找列表中最小元素的方法。和 Sort.sort 一样,在真正完成方法之前先写测试。首先创建一个假的 findSmallest,让它随便返回某个值:

public class Sort {
    /** Sorts strings destructively. */
    public static void sort(String[] x) { 
           // find the smallest item
           // move it to the front
           // selection sort the rest (using recursion?)
    }

    /** Returns the smallest string in x. */
    public static String findSmallest(String[] x) {
        return x[2];
    }
}

这显然不是正确实现,但我们决定先完成测试,再认真思考 findSmallest 的逻辑。借助 org.junit,向 TestSort 添加测试非常简单:

public class TestSort {
    ...
    public static void testFindSmallest() {
        String[] input = {"i", "have", "an", "egg"};
        String expected = "an";

        String actual = Sort.findSmallest(input);
        org.junit.Assert.assertEquals(expected, actual);        
    }

    public static void main(String[] args) {
        testFindSmallest(); // note: we changed this from testSort!
    }
}

TestSort.testSort 一样,先运行 TestSort.testFindSmallest,确保测试能够失败。但这一次测试居然通过了,没有任何信息出现,因为我们碰巧硬编码了正确返回值 x[2]。把方法改成一个肯定错误的返回值:

/** Returns the smallest string in x. */
public static String findSmallest(String[] x) {
    return x[3];
}

修改后运行测试,会得到错误。这是好事:

Exception in thread "main" java.lang.AssertionError: expected:<[an]> but was:<[null]>
    at org.junit.Assert.failNotEquals(Assert.juava:834)
    at TestSort.testFindSmallest(TestSort.java:9)
    at TestSort.main(TestSort.java:24)

和之前一样,我们又建立了一个小游戏:修改 Sort.findSmallest,直到错误消失。这个目标比一次写好整个 Sort.sort 更小,甚至可能更令人上瘾。

顺便说一句:我恰好返回正确值 x[2] 看起来像刻意安排,但在录制课程视频时,我真的无意中犯了完全相同的错误。

现在真正编写 findSmallest。它似乎相当简单。Java 初学者可能写出:

/**  Returns the smallest string in x. */
public static String findSmallest(String[] x) {
    String smallest = x[0];
    for (int i = 0; i < x.length; i += 1) {
        if (x[i] < smallest) {
            smallest = x[i];
        }
    }
    return smallest;
}

但这会产生编译错误:“< cannot be applied to java.lang.String”。问题是 Java 不允许使用 < 比较字符串。

编程中遇到这种容易描述的问题时,通常最好使用搜索引擎。例如可以搜索 “less than strings Java”,结果可能包含这篇 Stack Overflow 帖子

其中一个热门回答解释:str1.compareTo(str2)str1 < str2 时返回负数,两者相等时返回 0,str1 > str2 时返回正数。

把这一点加入代码后,得到:

/** Returns the smallest string in x. 
  * @source Got help with string compares from https://goo.gl/a7yBU5. */
public static String findSmallest(String[] x) {
    String smallest = x[0];
    for (int i = 0; i < x.length; i += 1) {
        int cmp = x[i].compareTo(smallest);
        if (cmp < 0) {
            smallest = x[i];
        }
    }
    return smallest;
}

注意,我们使用 @source 标签注明资料来源。这里主要是给正式修读 CS61B 的同学做示范,现实工作中并不通常使用这种标签。

由于使用了完全陌生的语法,我们可能不太确信 findSmallest 是否正确。幸运的是,刚刚已经编写了测试。运行后没有输出,说明代码很可能正确。

还可以增加更多测试用例来提高信心。例如把 testFindSmallest 改成:

public static void testFindSmallest() {
    String[] input = {"i", "have", "an", "egg"};
    String expected = "an";

    String actual = Sort.findSmallest(input);
    org.junit.Assert.assertEquals(expected, actual);        

    String[] input2 = {"there", "are", "many", "pigs"};
    String expected2 = "are";

    String actual2 = Sort.findSmallest(input2);
    org.junit.Assert.assertEquals(expected2, actual2);
}

再次运行,测试仍然通过。我们依然不能百分之百确定方法正确,但与完全没有测试相比,信心已经大得多。

swap

视频讲解

回到下面的 sort 方法,接下来需要一个把元素移到最前面的辅助方法,命名为 swap

/** Sorts strings destructively. */
public static void sort(String[] x) { 
       // find the smallest item
       // move it to the front
       // selection sort the rest (using recursion?)
}

编写交换方法非常直接,你以前可能已经做过。正确实现可以是:

public static void swap(String[] x, int a, int b) {
    String temp = x[a];
    x[a] = x[b];
    x[b] = temp;
}

但为了展示测试的用途,暂时故意引入一个错误。经验不足的程序员可能会写成:

public static void swap(String[] x, int a, int b) {    
    x[a] = x[b];
    x[b] = x[a];
}

借助 JUnit,为该方法编写测试很容易。下面还修改了 main,让它调用 testSwap,而不是 testFindSmallesttestSort

public class TestSort {
    ...    

    /** Test the Sort.swap method. */
    public static void testSwap() {
        String[] input = {"i", "have", "an", "egg"};
        int a = 0;
        int b = 2;
        String[] expected = {"an", "have", "i", "egg"};

        Sort.swap(input, a, b);
        org.junit.Assert.assertArrayEquals(expected, input);
    }

    public static void main(String[] args) {
        testSwap();
    }
}

在错误的 swap 上运行测试,果然得到错误:

Exception in thread "main" arrays first differed in element [2]; expected:<[i]> but was:<[an]>
    at TestSort.testSwap(TestSort.java:36)

需要简要指出,只调用 testSwap、不同时调用 testSort 很重要。例如,如果 main 如下,那么 testSort 一失败,整个 main 就会终止,testSwap 永远不会运行:

public static void main(String[] args) {
    testSort();
    testFindSmallest();
    testSwap();
}

本章末尾会介绍更优雅的多测试处理方式,无需手动指定运行哪些测试。

现在已经有一个失败测试,可以用它辅助调试。一种方法是在 swap 中设置断点,使用 IntelliJ 的可视化调试功能。想进一步了解并练习调试,请完成实验 3。逐行执行代码后,错误会立刻变得清晰;加入本节开头的临时变量即可修复:

public static void swap(String[] x, int a, int b) {
    String temp = x[a];
    x[a] = x[b];
    x[b] = temp;
}

再次运行,测试通过。

修改 findSmallest

视频讲解

方法的多个组成部分已经完成,现在可以尝试把它们连接起来形成 Sort

/** Sorts strings destructively. */
public static void sort(String[] x) { 
       // find the smallest item
       // move it to the front
       // selection sort the rest (using recursion?)
}

findSmallestswap 各自怎样使用都很清楚,但一连接就会发现不匹配:findSmallest 返回 String,而 swap 需要两个下标。

/** Sorts strings destructively. */
public static void sort(String[] x) { 
       // find the smallest item
       String smallest = findSmallest(x);

       // move it to the front
       swap(x, 0, smallest);

       // selection sort the rest (using recursion?)
}

也就是说,findSmallest 应返回最小字符串的下标,而不是字符串本身。犯这种看似愚蠢的错误非常正常,也非常容易;如果你也遇到类似情况,不必焦虑。不断迭代设计本来就是写代码的一部分。

这个设计很容易修改,只需让 findSmallest 返回 int

public static int findSmallest(String[] x) {
    int smallestIndex = 0;
    for (int i = 0; i < x.length; i += 1) {
        int cmp = x[i].compareTo(x[smallestIndex]);
        if (cmp < 0) {
            smallestIndex = i;
        }
    }
    return smallestIndex;
}

这是一次非微小改动,因此也应更新 testFindSmallest,确认方法仍然工作:

public static void testFindSmallest() {
    String[] input = {"i", "have", "an", "egg"};
    int expected = 2;

    int actual = Sort.findSmallest(input);
    org.junit.Assert.assertEquals(expected, actual);        

    String[] input2 = {"there", "are", "many", "pigs"};
    int expected2 = 1;

    int actual2 = Sort.findSmallest(input);
    org.junit.Assert.assertEquals(expected2, actual2);
}

TestSort 运行该测试后,代码通过。现在回到 sort,可以完成选择排序的前两步:

/** Sorts strings destructively. */
public static void sort(String[] x) { 
   // find the smallest item
   // move it to the front
   // selection sort the rest (using recursion?)
   int smallestIndex = findSmallest(x);
   swap(x, 0, smallestIndex);
}

剩下的事情,是以某种方式继续对剩余元素执行选择排序,也许可以使用递归。下一节处理这个问题。

回顾目前的过程,值得注意的是:我们先创建测试,并利用测试确认基础方法可用,然后才尝试把它们用于其他任务。这是极其重要的思想,采用它会长期受益。

递归辅助方法

视频讲解

先思考如何完成 sort 所需的递归调用:

/** Sorts strings destructively. */
public static void sort(String[] x) { 
   int smallestIndex = findSmallest(x);
   swap(x, 0, smallestIndex);
   // recursive call??
}

习惯 Python 等语言的同学,可能会想使用切片语法:

/** Sorts strings destructively. */
public static void sort(String[] x) { 
   int smallestIndex = findSmallest(x);
   swap(x, 0, smallestIndex);
   sort(x[1:])
}

但 Java 中不存在对子数组的这种引用,也就是说,不能简单传入数组下一个元素的地址。

只处理大数组中的一部分,是很常见的问题。典型解决方案是创建私有辅助方法,增加一个或多个参数,标明要处理数组的哪一部分。例如,可以再写一个也名为 sort 的私有辅助方法,只考虑从 start 开始的元素。

/** Sorts strings destructively starting from item start. */
private static void sort(String[] x, int start) { 
    // TODO
}

与公开的 sort 相比,增加 start 后,递归就容易多了。下一节会测试这个方法。

/** Sorts strings destructively starting from item start. */
private static void sort(String[] x, int start) { 
   int smallestIndex = findSmallest(x);
   swap(x, start, smallestIndex);
   sort(x, start + 1);
}

有了辅助方法,还需要设置正确的初始调用。令 start 为 0,就等于对整个数组排序。

/** Sorts strings destructively. */
public static void sort(String[] x) { 
   sort(x, 0);
}

当我们想对数组等并非天然递归的数据结构使用递归时,这是一种非常常见的方法。

调试并完成排序

视频讲解

运行 testSort 后立刻遇到问题:

Exception in thread "main" java.lang.ArrayIndexOutOfBoundsException: 4
    at Sort.swap(Sort.java:16)

使用 Java 调试器后发现,start 不知为何达到了 4。仔细逐行执行,会发现我们忘记给递归 sort 添加基本情况。修复很直接:

/** Sorts strings destructively starting from item start. */
private static void sort(String[] x, int start) { 
   if (start == x.length) {
       return;
   }
   int smallestIndex = findSmallest(x);
   swap(x, start, smallestIndex);
   sort(x, start + 1);
}

再次运行,又得到另一个错误:

Exception in thread "main" arrays first differed at element [0]; 
   expected<[an]> bit was:<[have]>

再次合理使用 IntelliJ 调试器,可以找到某行代码的结果不符合预期。这里值得注意的是,调试时我采用了比你可能想到的更高抽象层级:更多使用 Step Over,而不是 Step Into。正如实验 3 所述,在更高抽象层级上调试,可以直接比较整个函数调用结果与预期,从而节省大量时间和精力。

具体来说,对 {"an", "have", "i", "egg"} 的最后三个元素排序时,findSmallest 给出的仍是第 0 个元素 "an",而不是第 3 个元素 "egg"。仔细看定义就会发现,这并不意外:findSmallest 检查整个数组,而不是只检查从 start 开始的元素。这种设计缺陷非常常见,编写测试并使用调试器是修复它们的好方法。

为修复代码,让 findSmallest 接受第二个参数 start,即 findSmallest(String[] x, int start)。这样它只会在尚未排序的末尾部分寻找最小元素:

public static int findSmallest(String[] x, int start) {
    int smallestIndex = start;
    for (int i = start; i < x.length; i += 1) {
        int cmp = x[i].compareTo(x[smallestIndex]);
        if (cmp < 0) {
            smallestIndex = i;
        }
    }
    return smallestIndex;
}

我们对一个基础构件作了重要修改,因此应确保修改正确。

首先修改 testFindSmallest,使用新参数:

public static void testFindSmallest() {
    String[] input = {"i", "have", "an", "egg"};
    int expected = 2;

    int actual = Sort.findSmallest(input, 0);
    org.junit.Assert.assertEquals(expected, actual);        

    String[] input2 = {"there", "are", "many", "pigs"};
    int expected2 = 2;

    int actual2 = Sort.findSmallest(input2, 2);
    org.junit.Assert.assertEquals(expected2, actual2);
}

再修改 TestSort.main,让它运行 testFindSmallest。测试通过,强烈说明修改正确。

接着让 Sort.sort 在调用 findSmallest 时使用新的 start 参数:

/** Sorts strings destructively starting from item start. */
private static void sort(String[] x, int start) { 
   if (start == x.length) {
       return;
   }
   int smallestIndex = findSmallest(x, start);
   swap(x, start, smallestIndex);
   sort(x, start + 1);
}

最后让 TestSort 运行 TestSort.sort。成功了,方法可以正常工作。至此完成任务,也真正体验了本章开头所说的“新方式”。本章余下部分将反思这一过程。

对开发过程的反思

视频讲解

编写和调试程序时,经常需要在不同上下文之间切换。试图同时把过多内容放在脑中,最坏会导致灾难,最好也会拖慢进度。

自动化测试可以降低这种认知负担。例如,编写 sort 的过程中发现 findSmallest 有错误。我们可以切换上下文,专注于 findSmallest,通过 testFindSmallest 确认它正确,再回到 sort。相比之下,更原始的做法是不断调用 sort,试图通过整体算法的表现猜测 findSmallest 是否正确。

打个比方,你可以这样测试降落伞的拉绳:登上飞机、起飞、跳出去、拉动绳子,再看看降落伞是否打开;也可以站在地面直接拉一下看看。类似地,没必要通过运行完整 sort 来试验 findSmallest

如前所述,测试还会让你对程序的基础组成部分更有信心。一旦出错,也更清楚应从哪里开始检查。

最后,测试使重构代码更安全。假设你决定重写 findSmallest,使它更快或更易读。可以放心修改,只需确认测试仍然通过。

更好的 JUnit 写法

视频讲解

先回顾今天看到的新语法:org.junit.Assert.assertEquals(expected, actual)。这个名称很长的方法检查 expectedactual 是否相等;如果不相等,它会终止程序并给出详细错误信息。

JUnit 除了 assertEquals 还有很多方法,例如 assertFalseassertNotNullfail 等,可以查阅 JUnit 官方文档。JUnit 还有许多更复杂的功能,CS61B 不会介绍,但你可以自由使用。

JUnit 已经带来明显改进,但之前的测试代码仍有些笨拙。本节余下部分介绍两项主要增强,让代码更整洁、更易用。从语法角度看,它们可能很神秘;目前先照着写,后续章节会解释其中一部分。

第一项增强是使用“测试注解”:

  • 在每个测试方法前添加 @org.junit.Test,后面不写分号。
  • 把每个测试方法改为非静态方法。
  • TestSort 类中删除 main 方法。

完成这三步后,通过 JUnit 的 Run -> Run 命令重新运行代码,所有测试都会自动执行,不再需要手动调用。基于注解的方法有以下优点:

  • 无需手动调用测试。
  • 所有测试都会执行,而不只是手动指定的测试。
  • 一个测试失败后,其他测试仍会继续运行。
  • 会显示运行了多少测试,以及多少测试通过。
  • 测试失败时,错误信息更美观。
  • 所有测试通过时会显示友好信息和绿色进度条,而不是完全没有输出。

第二项增强可以缩短很长的方法名和注解名,使用的是“导入语句”。

首先在文件顶部加入 import org.junit.Test;。之后可以把所有 @org.junit.Test 简写为 @Test

再加入 import static org.junit.Assert.*;。之后凡是出现 org.junit.Assert. 的地方都可以省略。例如,org.junit.Assert.assertEquals(expected2, actual2); 可以直接写成 assertEquals(expected2, actual2);

后续课程会解释导入语句为什么这样工作。目前先使用并享受它带来的便利。

测试理念

视频讲解

正确性工具 1:自动评分器

回到最初。自动评分器很可能是你接触的第一个正确性工具。我们的自动评分器实际上基于 JUnit,并加入了一些自定义库。

自动评分器有不少优点。最重要的是,它会替你验证正确性,省去自行编写全部测试这一繁琐而不一定有教学价值的任务。它还通过分数把评估过程游戏化,激励你追求正确结果。不过这也可能适得其反:学生为了最后几个并不会影响成绩或学习效果的分数,耗费过多时间。

现实世界中通常没有自动评分器,过度依赖它会养成坏习惯。偶尔上传代码并等待评分器运行,也会破坏工作流。_自动评分器驱动开发_是这种做法的极端版本:学生一次写完所有代码,修复编译错误,然后提交给评分器;收到错误后,随便修改一些地方、加入输出语句,再次提交,不断重复。如果依赖自动评分器,你最终既无法控制工作流程,也无法真正控制自己的代码。

正确性工具 2:JUnit 测试

正如我们已经看到的,JUnit 打开了一个新世界。你不再依赖别人编写的评分器,而是为程序中的每个组成部分编写测试。每个这样的部分称为一个“单元”。这使你能信任代码的每个单元。它也能缩短调试时间,因为可以一次只关注一个代码单元,通常就是一个方法。单元测试还会迫使你明确每个单元究竟应完成什么任务。

单元测试也有缺点。首先,编写全面测试需要时间。很容易写出不完整的单元测试,从而对代码产生虚假信心。对于依赖其他单元的单元,测试也比较困难,例如 LinkedListDeque 中的 addFirst

测试驱动开发(TDD)

TDD 是一种先写测试、再写代码的开发流程。步骤如下:

  1. 确定一个新功能。
  2. 为该功能编写单元测试。
  3. 运行测试,它此时应当失败。
  4. 编写能够通过测试的代码。成功!
  5. 可选:重构代码,使它更快、更整洁等。此时已经有一套测试作为安全参照。

本课程不要求测试驱动开发,它也未必适合你的个人风格;但一般意义上的单元测试绝对是好主意。

正确性工具 3:集成测试

单元测试很好,但还应确保各个单元能够正确协作,而不是出现这个表情包中的情况。集成测试验证组件之间能否正确交互,JUnit 同样可以用于集成测试。可以把单元测试理解为最细粒度的层级,而集成测试位于更高一层抽象。

集成测试的挑战在于:手动执行很繁琐,自动化又很困难。而且在较高抽象层级上,很容易漏掉细微或罕见的错误。

总结来说:一定要写测试,但只在测试可能真正有用时写。 借鉴 TDD,在某些情况下先写测试再写代码也非常有帮助。

接下来做什么