16-Validating-Your-Code
第十六章 代码校验
你永远不能保证你的代码是正确的,你只能证明它是错的。
让我们先暂停编程语言特性的学习,看看一些代码基础知识。特别是能让你的代码更加健壮的知识。
测试
如果没有测试过,它就是不能工作的。
单元测试
这个过程是将集成测试构建到你创建的所有代码中,并在每次构建系统时运行这些测试。这样,构建过程不仅能检查语法的错误,同时也能检查语义的错误。
“单元”是指测试一小部分代码 。通常,每个类都有测试来检查它所有方法的行为
我自己的测试经历开始于我意识到要确保书中代码的正确性,书中的所有程序必须能够通过合适的构建系统自动提取、编译。这本书所使用的构建系统是
JUnit
最初的
在
让我们尝试一个简单的例子。
// validating/CountedList.java
// Keeps track of how many of itself are created.
package validating;
import java.util.*;
public class CountedList extends ArrayList<String> {
private static int counter = 0;
private int id = counter++;
public CountedList() {
System.out.println("CountedList #" + id);
}
public int getId() { return id; }
}
标准做法是将测试放在它们自己的子目录中。测试还必须放在包中,以便
// validating/tests/CountedListTest.java
// Simple use of JUnit to test CountedList.
package validating;
import java.util.*;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
public class CountedListTest {
private CountedList list;
@BeforeAll
static void beforeAllMsg() {
System.out.println(">>> Starting CountedListTest");
}
@AfterAll
static void afterAllMsg() {
System.out.println(">>> Finished CountedListTest");
}
@BeforeEach
public void initialize() {
list = new CountedList();
System.out.println("Set up for " + list.getId());
for(int i = 0; i < 3; i++)
list.add(Integer.toString(i));
}
@AfterEach
public void cleanup() {
System.out.println("Cleaning up " + list.getId());
}
@Test
public void insert() {
System.out.println("Running testInsert()");
assertEquals(list.size(), 3);
list.add(1, "Insert");
assertEquals(list.size(), 4);
assertEquals(list.get(1), "Insert");
}
@Test
public void replace() {
System.out.println("Running testReplace()");
assertEquals(list.size(), 3);
list.set(1, "Replace");
assertEquals(list.size(), 3);
assertEquals(list.get(1), "Replace");
}
// A helper method to simplify the code. As
// long as it's not annotated with @Test, it will
// not be automatically executed by JUnit.
private void compare(List<String> lst, String[] strs) {
assertArrayEquals(lst.toArray(new String[0]), strs);
}
@Test
public void order() {
System.out.println("Running testOrder()");
compare(list, new String[] { "0", "1", "2" });
}
@Test
public void remove() {
System.out.println("Running testRemove()");
assertEquals(list.size(), 3);
list.remove(1);
assertEquals(list.size(), 2);
compare(list, new String[] { "0", "2" });
}
@Test
public void addAll() {
System.out.println("Running testAddAll()");
list.addAll(Arrays.asList(new String[] {
"An", "African", "Swallow"}));
assertEquals(list.size(), 6);
compare(list, new String[] { "0", "1", "2",
"An", "African", "Swallow" });
}
}
/* Output:
>>> Starting CountedListTest
CountedList #0
Set up for 0
Running testRemove()
Cleaning up 0
CountedList #1
Set up for 1
Running testReplace()
Cleaning up 1
CountedList #2
Set up for 2
Running testAddAll()
Cleaning up 2
CountedList #3
Set up for 3
Running testInsert()
Cleaning up 3
CountedList #4
Set up for 4
Running testOrder()
Cleaning up 4
>>> Finished CountedListTest
*/
如果你必须在每次测试后执行清理(如果修改了需要恢复的静态文件,打开文件需要关闭,打开数据库或者网络连接,etc
每个测试创建一个新的
断言语句不是必须的;你可以在没有断言的情况下运行测试,如果没有异常,则认为测试是成功的。
本书使用gradlew validating:test
,gradlew validating:clean
。
可以用下面这个命令运行本书的所有测试:
gradlew test
尽管可以用最简单的方法,如
测试覆盖率的幻觉
测试覆盖率,同样也称为代码覆盖率,度量代码的测试百分比。百分比越高,测试的覆盖率越大。这里有很多方法
计算覆盖率,还有有帮助的文章
对于没有知识但处于控制地位的人来说,很容易在没有任何了解的情况下也有概念认为
当分析一个未知的代码库时,测试覆盖率作为一个粗略的度量是有用的。如果覆盖率工具报告的值特别低(比如,少于百分之
前置条件
前置条件的概念来自于契约式设计
断言(Assertions)
断言通过验证在程序执行期间满足某些条件,从而增加了程序的健壮性。举例,假设在一个对象中有一个数值字段表示日历上的月份。这个数字总是介于
Java 断言语法
你可以通过其它程序设计架构来模拟断言的效果,因此,在
assert boolean-expression;
assert boolean-expression: information-expression;
两者似乎告诉我们 “我断言这个布尔表达式会产生
不幸的是,第一种断言形式的异常不会生成包含布尔表达式的任何信息(与大多数其他语言的断言机制相反
下面是第一种形式的例子:
// validating/Assert1.java
// Non-informative style of assert
// Must run using -ea flag:
// {java -ea Assert1}
// {ThrowsException}
public class Assert1 {
public static void main(String[] args) {
assert false;
}
}
/* Output:
___[ Error Output ]___
Exception in thread "main" java.lang.AssertionError
at Assert1.main(Assert1.java:9)
*/
如果你正常运行程序,没有任何特殊的断言标志,则不会发生任何事情。你需要在运行程序时显式启用断言。一种简单的方法是使用
输出中并没有包含多少有用的信息。另一方面,如果你使用
// validating/Assert2.java
// Assert with an information-expression
// {java Assert2 -ea}
// {ThrowsException}
public class Assert2 {
public static void main(String[] args) {
assert false:
"Here's a message saying what happened";
}
}
/* Output:
___[ Error Output ]___
Exception in thread "main" java.lang.AssertionError:
Here's a message saying what happened
at Assert2.main(Assert2.java:8)
*/
你还可以基于类名或包名打开或关闭断言;也就是说,你可以对整个包启用或禁用断言。实现这一点的详细信息在
你还可以通过编程的方式通过链接到类加载器对象(ClassLoader)来控制断言。类加载器中有几种方法允许动态启用和禁用断言,其中
// validating/LoaderAssertions.java
// Using the class loader to enable assertions
// {ThrowsException}
public class LoaderAssertions {
public static void main(String[] args) {
ClassLoader.getSystemClassLoader().
setDefaultAssertionStatus(true);
new Loaded().go();
}
}
class Loaded {
public void go() {
assert false: "Loaded.go()";
}
}
/* Output:
___[ Error Output ]___
Exception in thread "main" java.lang.AssertionError:
Loaded.go()
at Loaded.go(LoaderAssertions.java:15)
at
LoaderAssertions.main(LoaderAssertions.java:9)
*/
这消除了在运行程序时在命令行上使用
static {
boolean assertionsEnabled = false;
// Note intentional side effect of assignment:
assert assertionsEnabled = true;
if(!assertionsEnabled)
throw new RuntimeException("Assertions disabled");
}
如果启用断言,然后执行
Guava 断言
因为启用
// validating/GuavaAssertions.java
// Assertions that are always enabled.
import com.google.common.base.*;
import static com.google.common.base.Verify.*;
public class GuavaAssertions {
public static void main(String[] args) {
verify(2 + 2 == 4);
try {
verify(1 + 2 == 4);
} catch(VerifyException e) {
System.out.println(e);
}
try {
verify(1 + 2 == 4, "Bad math");
} catch(VerifyException e) {
System.out.println(e.getMessage());
}
try {
verify(1 + 2 == 4, "Bad math: %s", "not 4");
} catch(VerifyException e) {
System.out.println(e.getMessage());
}
String s = "";
s = verifyNotNull(s);
s = null;
try {
verifyNotNull(s);
} catch(VerifyException e) {
System.out.println(e.getMessage());
}
try {
verifyNotNull(
s, "Shouldn't be null: %s", "arg s");
} catch(VerifyException e) {
System.out.println(e.getMessage());
}
}
}
/* Output:
com.google.common.base.VerifyException
Bad math
Bad math: not 4
expected a non-null reference
Shouldn't be null: arg s
*/
这里有两个方法,使用变量
使用断言进行契约式设计
不管你是否同意,第一条总是对的,在大多数情况下,
检查指令
详细研究
在化学领域,你也许会用一种纯液体去滴定测量另一种液体,当达到一个特定的点时,液体变蓝了。从两个液体的颜色上并不能明显看出;这是复杂反应的一部分。滴定完成后一个有用的检查指令是能够断定液体变蓝了。
检查指令是对你的代码进行补充,当你可以测试并阐明对象或程序的状态时,应该使用它。
前置条件
前置条件确保客户端
后置条件
后置条件测试你在方法中所做的操作的结果。这段代码放在方法调用的末尾,在
不变性
不变性保证了必须在方法调用之间维护的对象的状态。但是,它并不会阻止方法在执行过程中暂时偏离这些保证,它只是在说对象的状态信息应该总是遵守状态规则:
此外,不变性是构造后对于对象状态的保证。
根据这个描述,一个有效的不变性被定义为一个方法,可能被命名为
assert invariant();
这样,如果出于性能原因禁用断言,就不会产生开销。
放松DbC 检查或非严格的DbC
尽管
你不应该直接删除检查的代码,而只需要禁用检查
DbC + 单元测试
下面的例子演示了将契约式设计中的概念与单元测试相结合的有效性。它显示了一个简单的先进先出
我们可以对这个队列做一些契约定义
下面是实现这些规则的一种方式,为每个
首先,我们创建一个专用的
// validating/CircularQueueException.java
package validating;
public class CircularQueueException extends RuntimeException {
public CircularQueueException(String why) {
super(why);
}
}
它用来报告
// validating/CircularQueue.java
// Demonstration of Design by Contract (DbC)
package validating;
import java.util.*;
public class CircularQueue {
private Object[] data;
private int in = 0, // Next available storage space
out = 0; // Next gettable object
// Has it wrapped around the circular queue?
private boolean wrapped = false;
public CircularQueue(int size) {
data = new Object[size];
// Must be true after construction:
assert invariant();
}
public boolean empty() {
return !wrapped && in == out;
}
public boolean full() {
return wrapped && in == out;
}
public boolean isWrapped() { return wrapped; }
public void put(Object item) {
precondition(item != null, "put() null item");
precondition(!full(),
"put() into full CircularQueue");
assert invariant();
data[in++] = item;
if(in >= data.length) {
in = 0;
wrapped = true;
}
assert invariant();
}
public Object get() {
precondition(!empty(),
"get() from empty CircularQueue");
assert invariant();
Object returnVal = data[out];
data[out] = null;
out++;
if(out >= data.length) {
out = 0;
wrapped = false;
}
assert postcondition(
returnVal != null,
"Null item in CircularQueue");
assert invariant();
return returnVal;
}
// Design-by-contract support methods:
private static void precondition(boolean cond, String msg) {
if(!cond) throw new CircularQueueException(msg);
}
private static boolean postcondition(boolean cond, String msg) {
if(!cond) throw new CircularQueueException(msg);
return true;
}
private boolean invariant() {
// Guarantee that no null values are in the
// region of 'data' that holds objects:
for(int i = out; i != in; i = (i + 1) % data.length)
if(data[i] == null)
throw new CircularQueueException("null in CircularQueue");
// Guarantee that only null values are outside the
// region of 'data' that holds objects:
if(full()) return true;
for(int i = in; i != out; i = (i + 1) % data.length)
if(data[i] != null)
throw new CircularQueueException(
"non-null outside of CircularQueue range: " + dump());
return true;
}
public String dump() {
return "in = " + in +
", out = " + out +
", full() = " + full() +
", empty() = " + empty() +
", CircularQueue = " + Arrays.asList(data);
}
}
注意,
现在我们可以为类创建
// validating/tests/CircularQueueTest.java
package validating;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
public class CircularQueueTest {
private CircularQueue queue = new CircularQueue(10);
private int i = 0;
@BeforeEach
public void initialize() {
while(i < 5) // Pre-load with some data
queue.put(Integer.toString(i++));
}
// Support methods:
private void showFullness() {
assertTrue(queue.full());
assertFalse(queue.empty());
System.out.println(queue.dump());
}
private void showEmptiness() {
assertFalse(queue.full());
assertTrue(queue.empty());
System.out.println(queue.dump());
}
@Test
public void full() {
System.out.println("testFull");
System.out.println(queue.dump());
System.out.println(queue.get());
System.out.println(queue.get());
while(!queue.full())
queue.put(Integer.toString(i++));
String msg = "";
try {
queue.put("");
} catch(CircularQueueException e) {
msg = e.getMessage();
∂System.out.println(msg);
}
assertEquals(msg, "put() into full CircularQueue");
showFullness();
}
@Test
public void empty() {
System.out.println("testEmpty");
while(!queue.empty())
System.out.println(queue.get());
String msg = "";
try {
queue.get();
} catch(CircularQueueException e) {
msg = e.getMessage();
System.out.println(msg);
}
assertEquals(msg, "get() from empty CircularQueue");
showEmptiness();
}
@Test
public void nullPut() {
System.out.println("testNullPut");
String msg = "";
try {
queue.put(null);
} catch(CircularQueueException e) {
msg = e.getMessage();
System.out.println(msg);
}
assertEquals(msg, "put() null item");
}
@Test
public void circularity() {
System.out.println("testCircularity");
while(!queue.full())
queue.put(Integer.toString(i++));
showFullness();
assertTrue(queue.isWrapped());
while(!queue.empty())
System.out.println(queue.get());
showEmptiness();
while(!queue.full())
queue.put(Integer.toString(i++));
showFullness();
while(!queue.empty())
System.out.println(queue.get());
showEmptiness();
}
}
/* Output:
testNullPut
put() null item
testCircularity
in = 0, out = 0, full() = true, empty() = false,
CircularQueue =
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
0
1
2
3
4
5
6
7
8
9
in = 0, out = 0, full() = false, empty() = true,
CircularQueue =
[null, null, null, null, null, null, null, null, null,
null]
in = 0, out = 0, full() = true, empty() = false,
CircularQueue =
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
10
11
12
13
14
15
16
17
18
19
in = 0, out = 0, full() = false, empty() = true,
CircularQueue =
[null, null, null, null, null, null, null, null, null,
null]
testFull
in = 5, out = 0, full() = false, empty() = false,
CircularQueue =
[0, 1, 2, 3, 4, null, null, null, null, null]
0
1
put() into full CircularQueue
in = 2, out = 2, full() = true, empty() = false,
CircularQueue =
[10, 11, 2, 3, 4, 5, 6, 7, 8, 9]
testEmpty
0
1
2
3
4
get() from empty CircularQueue
in = 5, out = 5, full() = false, empty() = true,
CircularQueue =
[null, null, null, null, null, null, null, null, null,
null]
*/
通过将
使用Guava 前置条件
在非严格的
谷歌的
// validating/GuavaPreconditions.java
// Demonstrating Guava Preconditions
import java.util.function.*;
import static com.google.common.base.Preconditions.*;
public class GuavaPreconditions {
static void test(Consumer<String> c, String s) {
try {
System.out.println(s);
c.accept(s);
System.out.println("Success");
} catch(Exception e) {
String type = e.getClass().getSimpleName();
String msg = e.getMessage();
System.out.println(type +
(msg == null ? "" : ": " + msg));
}
}
public static void main(String[] args) {
test(s -> s = checkNotNull(s), "X");
test(s -> s = checkNotNull(s), null);
test(s -> s = checkNotNull(s, "s was null"), null);
test(s -> s = checkNotNull(
s, "s was null, %s %s", "arg2", "arg3"), null);
test(s -> checkArgument(s == "Fozzie"), "Fozzie");
test(s -> checkArgument(s == "Fozzie"), "X");
test(s -> checkArgument(s == "Fozzie"), null);
test(s -> checkArgument(
s == "Fozzie", "Bear Left!"), null);
test(s -> checkArgument(
s == "Fozzie", "Bear Left! %s Right!", "Frog"),
null);
test(s -> checkState(s.length() > 6), "Mortimer");
test(s -> checkState(s.length() > 6), "Mort");
test(s -> checkState(s.length() > 6), null);
test(s ->
checkElementIndex(6, s.length()), "Robert");
test(s ->
checkElementIndex(6, s.length()), "Bob");
test(s ->
checkElementIndex(6, s.length()), null);
test(s ->
checkPositionIndex(6, s.length()), "Robert");
test(s ->
checkPositionIndex(6, s.length()), "Bob");
test(s ->
checkPositionIndex(6, s.length()), null);
test(s -> checkPositionIndexes(
0, 6, s.length()), "Hieronymus");
test(s -> checkPositionIndexes(
0, 10, s.length()), "Hieronymus");
test(s -> checkPositionIndexes(
0, 11, s.length()), "Hieronymus");
test(s -> checkPositionIndexes(
-1, 6, s.length()), "Hieronymus");
test(s -> checkPositionIndexes(
7, 6, s.length()), "Hieronymus");
test(s -> checkPositionIndexes(
0, 6, s.length()), null);
}
}
/* Output:
X
Success
null
NullPointerException
null
NullPointerException: s was null
null
NullPointerException: s was null, arg2 arg3
Fozzie
Success
X
IllegalArgumentException
null
IllegalArgumentException
null
IllegalArgumentException: Bear Left!
null
IllegalArgumentException: Bear Left! Frog Right!
Mortimer
Success
Mort
IllegalStateException
null
NullPointerException
Robert
IndexOutOfBoundsException: index (6) must be less than
size (6)
Bob
IndexOutOfBoundsException: index (6) must be less than
size (3)
null
NullPointerException
Robert
Success
Bob
IndexOutOfBoundsException: index (6) must not be
greater than size (3)
null
NullPointerException
Hieronymus
Success
Hieronymus
Success
Hieronymus
IndexOutOfBoundsException: end index (11) must not be
greater than size (10)
Hieronymus
IndexOutOfBoundsException: start index (-1) must not be
negative
Hieronymus
IndexOutOfBoundsException: end index (6) must not be
less than start index (7)
null
NullPointerException
*/
虽然
每个前置条件都有三种不同的重载形式:一个什么都没有,一个带有简单字符串消息,以及带有一个字符串和替换值。为了提高效率,只允许
/ validating/NonNullConstruction.java
import static com.google.common.base.Preconditions.*;
public class NonNullConstruction {
private Integer n;
private String s;
NonNullConstruction(Integer n, String s) {
this.n = checkNotNull(n);
this.s = checkNotNull(s);
}
public static void main(String[] args) {
NonNullConstruction nnc =
new NonNullConstruction(3, "Trousers");
}
}
最后三个方法在失败时抛出
所有的
测试驱动开发
之所以可以有测试驱动开发(TDD)这种开发方式,是因为如果你在设计和编写代码时考虑到了测试,那么你不仅可以写出可测试性更好的代码,而且还可以得到更好的代码设计。 一般情况下这个说法都是正确的。 一旦我想到“我将如何测试我的代码
纯粹的
此示例与标准
// validating/StringInverter.java
package validating;
interface StringInverter {
String invert(String str);
}
现在我们通过可以编写测试来表述我们的要求。 以下所述通常不是你编写测试的方式,但由于我们在此处有一个特殊的约束:我们要对
- 对象集合上的迭代器
(versions) ,这个迭代器在不同组的测试中是不同的。 迭代器生成的对象可以是任何类型,但是只能有一种对象生成,因此对于存在多个不同的对象类型时,必须人为地将它们打包成单个类型。 - Function,它从迭代器获取对象并生成描述测试的
String 。 - Consumer,它从迭代器获取对象并包含基于该对象的测试代码。
在此示例中,所有代码将在
// validating/tests/DynamicStringInverterTests.java
package validating;
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.DynamicTest.*;
class DynamicStringInverterTests {
// Combine operations to prevent code duplication:
Stream<DynamicTest> testVersions(String id,
Function<StringInverter, String> test) {
List<StringInverter> versions = Arrays.asList(
new Inverter1(), new Inverter2(),
new Inverter3(), new Inverter4());
return DynamicTest.stream(
versions.iterator(),
inverter -> inverter.getClass().getSimpleName(),
inverter -> {
System.out.println(
inverter.getClass().getSimpleName() +
": " + id);
try {
if(test.apply(inverter) != "fail")
System.out.println("Success");
} catch(Exception | Error e) {
System.out.println(
"Exception: " + e.getMessage());
}
}
);
}
String isEqual(String lval, String rval) {
if(lval.equals(rval))
return "success";
System.out.println("FAIL: " + lval + " != " + rval);
return "fail";
}
@BeforeAll
static void startMsg() {
System.out.println(
">>> Starting DynamicStringInverterTests <<<");
}
@AfterAll
static void endMsg() {
System.out.println(
">>> Finished DynamicStringInverterTests <<<");
}
@TestFactory
Stream<DynamicTest> basicInversion1() {
String in = "Exit, Pursued by a Bear.";
String out = "eXIT, pURSUED BY A bEAR.";
return testVersions(
"Basic inversion (should succeed)",
inverter -> isEqual(inverter.invert(in), out)
);
}
@TestFactory
Stream<DynamicTest> basicInversion2() {
return testVersions(
"Basic inversion (should fail)",
inverter -> isEqual(inverter.invert("X"), "X"));
}
@TestFactory
Stream<DynamicTest> disallowedCharacters() {
String disallowed = ";-_()*&^%$#@!~`0123456789";
return testVersions(
"Disallowed characters",
inverter -> {
String result = disallowed.chars()
.mapToObj(c -> {
String cc = Character.toString((char)c);
try {
inverter.invert(cc);
return "";
} catch(RuntimeException e) {
return cc;
}
}).collect(Collectors.joining(""));
if(result.length() == 0)
return "success";
System.out.println("Bad characters: " + result);
return "fail";
}
);
}
@TestFactory
Stream<DynamicTest> allowedCharacters() {
String lowcase = "abcdefghijklmnopqrstuvwxyz ,.";
String upcase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ ,.";
return testVersions(
"Allowed characters (should succeed)",
inverter -> {
assertEquals(inverter.invert(lowcase), upcase);
assertEquals(inverter.invert(upcase), lowcase);
return "success";
}
);
}
@TestFactory
Stream<DynamicTest> lengthNoGreaterThan30() {
String str = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
assertTrue(str.length() > 30);
return testVersions(
"Length must be less than 31 (throws exception)",
inverter -> inverter.invert(str)
);
}
@TestFactory
Stream<DynamicTest> lengthLessThan31() {
String str = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
assertTrue(str.length() < 31);
return testVersions(
"Length must be less than 31 (should succeed)",
inverter -> inverter.invert(str)
);
}
}
在一般的测试中,你可能认为在进行一个结果为失败的测试时应该停止代码构建。 但是在这里,我们只希望系统报告问题,但仍然继续运行,以便你可以看到不同版本的
每个使用
现在测试都已经准备好了,我们就可以开始实现
// validating/Inverter1.java
package validating;
public class Inverter1 implements StringInverter {
public String invert(String str) { return str; }
}
接下来我们实现反转操作:
// validating/Inverter2.java
package validating;
import static java.lang.Character.*;
public class Inverter2 implements StringInverter {
public String invert(String str) {
String result = "";
for(int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
result += isUpperCase(c) ?
toLowerCase(c) :
toUpperCase(c);
}
return result;
}
}
现在添加代码以确保输入不超过
// validating/Inverter3.java
package validating;
import static java.lang.Character.*;
public class Inverter3 implements StringInverter {
public String invert(String str) {
if(str.length() > 30)
throw new RuntimeException("argument too long!");
String result = "";
for(int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
result += isUpperCase(c) ?
toLowerCase(c) :
toUpperCase(c);
}
return result;
}
}
最后,我们排除了不允许的字符:
// validating/Inverter4.java
package validating;
import static java.lang.Character.*;
public class Inverter4 implements StringInverter {
static final String ALLOWED =
"abcdefghijklmnopqrstuvwxyz ,." +
"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
public String invert(String str) {
if(str.length() > 30)
throw new RuntimeException("argument too long!");
String result = "";
for(int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if(ALLOWED.indexOf(c) == -1)
throw new RuntimeException(c + " Not allowed");
result += isUpperCase(c) ?
toLowerCase(c) :
toUpperCase(c);
}
return result;
}
}
你将从测试输出中看到,每个版本的
// validating/tests/StringInverterTests.java
package validating;
import java.util.*;
import java.util.stream.*;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
public class StringInverterTests {
StringInverter inverter = new Inverter4();
@BeforeAll
static void startMsg() {
System.out.println(">>> StringInverterTests <<<");
}
@Test
void basicInversion1() {
String in = "Exit, Pursued by a Bear.";
String out = "eXIT, pURSUED BY A bEAR.";
assertEquals(inverter.invert(in), out);
}
@Test
void basicInversion2() {
expectThrows(Error.class, () -> {
assertEquals(inverter.invert("X"), "X");
});
}
@Test
void disallowedCharacters() {
String disallowed = ";-_()*&^%$#@!~`0123456789";
String result = disallowed.chars()
.mapToObj(c -> {
String cc = Character.toString((char)c);
try {
inverter.invert(cc);
return "";
} catch(RuntimeException e) {
return cc;
}
}).collect(Collectors.joining(""));
assertEquals(result, disallowed);
}
@Test
void allowedCharacters() {
String lowcase = "abcdefghijklmnopqrstuvwxyz ,.";
String upcase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ ,.";
assertEquals(inverter.invert(lowcase), upcase);
assertEquals(inverter.invert(upcase), lowcase);
}
@Test
void lengthNoGreaterThan30() {
String str = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
assertTrue(str.length() > 30);
expectThrows(RuntimeException.class, () -> {
inverter.invert(str);
});
}
@Test
void lengthLessThan31() {
String str = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
assertTrue(str.length() < 31);
inverter.invert(str);
}
}
你可以通过这种方式进行开发:一开始在测试中建立你期望程序应有的所有特性,然后你就能在实现中一步步添加功能,直到所有测试通过。 完成后,你还可以在将来通过这些测试来得知(或让其他任何人得知)当修复错误或添加功能时,代码是否被破坏了。
测试驱动vs. 测试优先
虽然我自己还没有达到测试优先的意识水平,但我最感兴趣的是来自测试优先中的“测试失败的书签”这一概念。 当你离开你的工作一段时间后,重新回到工作进展中,甚至找到你离开时工作到的地方有时会很有挑战性。 然而,以失败的测试为书签能让你找到之前停止的地方。 这似乎让你能更轻松地暂时离开你的工作,因为不用担心找不到工作进展的位置。
纯粹的测试优先编程的主要问题是它假设你事先了解了你正在解决的问题。 根据我自己的经验,我通常是从实验开始,而只有当我处理问题一段时间后,我对它的理解才会达到能给它编写测试的程度。 当然,偶尔会有一些问题在你开始之前就已经完全定义,但我个人并不常遇到这些问题。 实际上,可能用“面向测试的开发
日志
日志会给出正在运行的程序的各种信息。
在调试程序中,日志可以是普通状态数据,用于显示程序运行过程(例如,安装程序可能会记录安装过程中采取的步骤,存储文件的目录,程序的启动值等
在调试期间,日志也能带来好处。 如果没有日志,你可能会尝试通过插入
程序员在日志包可供使用之前,都只能依赖
if(debug) {
System.out.println("Debug info");
}
然后,当
业内普遍认为标准
// validating/SLF4JLogging.java
import org.slf4j.*;
public class SLF4JLogging {
private static Logger log =
LoggerFactory.getLogger(SLF4JLogging.class);
public static void main(String[] args) {
log.info("hello logging");
}
}
/* Output:
2017-05-09T06:07:53.418
[main] INFO SLF4JLogging - hello logging
*/
日志输出中的格式和信息,甚至输出是否正常或“错误”都取决于
如果我们修改
Aug 16, 2016 5:40:31 PM InfoLogging main INFO: hello logging
日志系统会检测日志消息处所在的类名和方法名。 但它不能保证这些名称是正确的,所以不要纠结于其准确性。
日志等级
// validating/SLF4JLevels.java
import org.slf4j.*;
public class SLF4JLevels {
private static Logger log =
LoggerFactory.getLogger(SLF4JLevels.class);
public static void main(String[] args) {
log.trace("Hello");
log.debug("Logging");
log.info("Using");
log.warn("the SLF4J");
log.error("Facade");
}
}
/* Output:
2017-05-09T06:07:52.846
[main] TRACE SLF4JLevels - Hello
2017-05-09T06:07:52.849
[main] DEBUG SLF4JLevels - Logging
2017-05-09T06:07:52.849
[main] INFO SLF4JLevels - Using
2017-05-09T06:07:52.850
[main] WARN SLF4JLevels - the SLF4J
2017-05-09T06:07:52.851
[main] ERROR SLF4JLevels - Facade
*/
你可以按等级来查找消息。 级别通常设置在单独的配置文件中,因此你可以重新配置而无需重新编译。 配置文件格式取决于你使用的后端日志包实现。 如
<!-- validating/logback.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT"
class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>
%d{yyyy-MM-dd'T'HH:mm:ss.SSS}
[%thread] %-5level %logger - %msg%n
</pattern>
</encoder>
</appender>
<root level="TRACE">
<appender-ref ref="STDOUT" />
</root>
</configuration>
你可以尝试将
这只是
调试
尽管聪明地使用
你也可能需要更加深入地理解程序,仅依靠打印日志做不到。此时你需要调试器。除了比打印语句更快更轻易地展示信息以外,调试器还可以设置断点,并在程序运行到这些断点处暂停程序。
使用调试器,可以展示任何时刻的程序状态,查看变量的值,一步一步运行程序,连接远程运行的程序等等。特别是当你构建较大规模的系统(
使用JDB 调试
假设你写了如下程序:
// validating/SimpleDebugging.java
// {ThrowsException}
public class SimpleDebugging {
private static void foo1() {
System.out.println("In foo1");
foo2();
}
private static void foo2() {
System.out.println("In foo2");
foo3();
}
private static void foo3() {
System.out.println("In foo3");
int j = 1;
j--;
int i = 5 / j;
}
public static void main(String[] args) {
foo1();
}
}
/* Output
In foo1
In foo2
In foo3
__[Error Output]__
Exception in thread "main"
java.lang.ArithmeticException: /by zero
at
SimpleDebugging.foo3(SimpleDebugging.java:17)
at
SimpleDebugging.foo2(SimpleDebugging.java:11)
at
SimpleDebugging.foo1(SimpleDebugging.java:7)
at
SimpleDebugging.main(SimpleDebugging.java:20)
首先看方法 foo3()
,问题很明显:除数是
为了运行
jdb SimpleDebugging
接着
这里展示了如何使用交互式追踪一个问题的调试历程:
Initializing jdb…
> catch Exception
>
表明
Deferring exception catch Exception.
It will be set after the class is loaded.
继续输入:
> run
现在程序将运行到下个断点处,在这个例子中就是异常发生的地方。下面是运行
run SimpleDebugging
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
>
VM Started: In foo1
In foo2
In foo3
Exception occurred: java.lang.ArithmeticException
(uncaught)“thread=main”,
SimpleDebugging.foo3(),line=16 bci=15
16 int i = 5 / j
程序运行到第
main[1] list
12 private static void foo3() {
13 System.out.println(“In foo3”);
14 int j = 1;
15 j–;
16 => int i = 5 / j;
17 }
18 public static void main(String[] args) {
19 foo1();
20 }
21 }
/* Output:
上述 =>
展示了程序将继续运行的执行点。你可以使用命令
命令
main[1] locals
Method arguments:
Local variables:
j = 0
命令
main[1] wherei
[1] SimpleDebugging.foo3(SimpleDebugging.java:16), pc =15
[2] SimpleDebugging.foo2(SimpleDebugging.java:10), pc = 8
[3] SimpleDebugging.foo1(SimpleDebugging.java:6), pc = 8
[4] SimpleDebugging.main(SimpleDebugging.java:19), pc = 10
因为命令
图形化调试器
使用类似
因此,尽管你可能一开始用
基准测试
我们应该忘掉微小的效率提升,说的就是这些
97% 的时间做的事:过早的优化是万恶之源。 —— Donald Knuth
如果你发现自己正在过早优化的滑坡上,你可能浪费了几个月的时间
基准测试意味着对代码或算法片段进行计时看哪个跑得更快,与下一节的分析和优化截然相反,分析优化是观察整个程序,找到程序中最耗时的部分。
可以简单地对一个代码片段的执行计时吗?在像
微基准测试
写一个计时工具类从而比较不同代码块的执行速度是具有吸引力的。看上去这会产生一些有用的数据。比如,这里有一个简单的
- 创建一个
Timer 对象,执行一些操作然后调用 Timer 的 duration() 方法产生以毫秒为单位的运行时间。 - 向静态的
duration() 方法中传入 Runnable 。任何符合Runnable 接口的类都有一个函数式方法 run() ,该方法没有入参,且没有返回。
// onjava/Timer.java
package onjava;
import static java.util.concurrent.TimeUnit.*;
public class Timer {
private long start = System.nanoTime();
public long duration() {
return NANOSECONDS.toMillis(System.nanoTime() - start);
}
public static long duration(Runnable test) {
Timer timer = new Timer();
test.run();
return timer.duration();
}
}
这是一个很直接的计时方式。难道我们不能只运行一些代码然后看它的运行时长吗?
有许多因素会影响你的结果,即使是生成提示符也会造成计时的混乱。这里举一个看上去天真的例子,它使用了 标准的
// validating/BadMicroBenchmark.java
// {ExcludeFromTravisCI}
import java.util.*;
import onjava.Timer;
public class BadMicroBenchmark {
static final int SIZE = 250_000_000;
public static void main(String[] args) {
try { // For machines with insufficient memory
long[] la = new long[SIZE];
System.out.println("setAll: " + Timer.duration(() -> Arrays.setAll(la, n -> n)));
System.out.println("parallelSetAll: " + Timer.duration(() -> Arrays.parallelSetAll(la, n -> n)));
} catch (OutOfMemoryError e) {
System.out.println("Insufficient memory");
System.exit(0);
}
}
}
/* Output
setAll: 272
parallelSetAll: 301
对于一个长度为
// validating/BadMicroBenchmark2.java
// Relying on a common resource
import java.util.*;
import onjava.Timer;
public class BadMicroBenchmark2 {
static final int SIZE = 5_000_000;
public static void main(String[] args) {
long[] la = new long[SIZE];
Random r = new Random();
System.out.println("parallelSetAll: " + Timer.duration(() -> Arrays.parallelSetAll(la, n -> r.nextLong())));
System.out.println("setAll: " + Timer.duration(() -> Arrays.setAll(la, n -> r.nextLong())));
SplittableRandom sr = new SplittableRandom();
System.out.println("parallelSetAll: " + Timer.duration(() -> Arrays.parallelSetAll(la, n -> sr.nextLong())));
System.out.println("setAll: " + Timer.duration(() -> Arrays.setAll(la, n -> sr.nextLong())));
}
}
/* Output
parallelSetAll: 1147
setAll: 174
parallelSetAll: 86
setAll: 39
这只考虑了微基准测试的问题。
优化器有时可以检测出你创建了没有使用的东西,或者是部分代码的运行结果对程序没有影响。如果它优化掉你的测试,那么你可能得到不好的结果。
一个良好的微基准测试系统能自动地弥补像这样的问题(和很多其他的问题)从而产生合理的结果,但是创建这么一套系统是非常棘手,需要深入的知识。
JMH 的引入
截止目前为止,唯一能产生像样结果的
你可以在命令行编写
// validating/jmh/JMH1.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
// Increase these three for more accuracy:
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH1 {
private long[] la;
@Setup
public void setup() {
la = new long[250_000_000];
}
@Benchmark
public void setAll() {
Arrays.setAll(la, n -> n);
}
public void parallelSetAll() {
Arrays.parallelSetAll(la, n -> n);
}
}
“forks” 的默认值是
需要使用显式的
gradlew validating:jmh
这会花费几分钟的时间,取决于你的机器
因为输出是绝对时间,所以在不同的机器和操作系统上结果各不相同。重要的因素不是绝对时间,我们真正观察的是一个算法和另一个算法的比较,尤其是哪一个运行得更快,快多少。如果你在自己的机器上运行测试,你将看到不同的结果却有着相同的模式。
我在大量的机器上运行了这些测试,尽管不同的机器上得到的绝对值结果不同,但是相对值保持着合理的稳定性。我只列出了
我同样也展示了使用
下面是
Benchmark Score
JMH1.setAll 196280.2
JMH1.parallelSetAll 195412.9
即使像
当创建这个示例时,我假设如果我们要测试数组初始化的话,那么使用非常大的数组是有意义的。所以我选择了尽可能大的数组;如果你实验的话会发现一旦数组的大小超过
考虑其他的因素:
C:客户端执行操作的线程数量
P:并行算法使用的并行数量
N:数组的大小:10^(2*k),通常来说,
Q:
这个
在一些情况下操作竞争如此激烈使得并行毫无帮助,而不管
基于这些信息,我们重新运行测试,并在这些测试中使用不同大小的数组(改变
// validating/jmh/JMH2.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH2 {
private long[] la;
@Param({
"1",
"10",
"100",
"1000",
"10000",
"100000",
"1000000",
"10000000",
"100000000",
"250000000"
})
int size;
@Setup
public void setup() {
la = new long[size];
}
@Benchmark
public void setAll() {
Arrays.setAll(la, n -> n);
}
@Benchmark
public void parallelSetAll() {
Arrays.parallelSetAll(la, n -> n);
}
}
下面是已经编辑过的结果,包含精确计算出的加速数值:
JMH2 Benchmark | Size | Score % | Speedup |
---|---|---|---|
setAll | 1 | 0.001 | |
parallelSetAll | 1 | 0.036 | 0.028 |
setAll | 10 | 0.005 | |
parallelSetAll | 10 | 3.965 | 0.001 |
setAll | 100 | 0.031 | |
parallelSetAll | 100 | 3.145 | 0.010 |
setAll | 1000 | 0.302 | |
parallelSetAll | 1000 | 3.285 | 0.092 |
setAll | 10000 | 3.152 | |
parallelSetAll | 10000 | 9.669 | 0.326 |
setAll | 100000 | 34.971 | |
parallelSetAll | 100000 | 20.153 | 1.735 |
setAll | 1000000 | 420.581 | |
parallelSetAll | 1000000 | 165.388 | 2.543 |
setAll | 10000000 | 8160.054 | |
parallelSetAll | 10000000 | 7610.190 | 1.072 |
setAll | 100000000 | 79128.752 | |
parallelSetAll | 100000000 | 76734.671 | 1.031 |
setAll | 250000000 | 199552.121 | |
parallelSetAll | 250000000 | 191791.927 | 1.040 |
可以看到当数组大小达到
我们通过使方法
// validating/jmh/JMH3.java
package validating.jmh;
import java.util.*;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5)
@Measurement(iterations = 5)
@Fork(1)
public class JMH3 {
private long[] la;
@Param({
"1",
"10",
"100",
"1000",
"10000",
"100000",
"1000000",
"10000000",
"100000000",
"250000000"
})
int size;
@Setup
public void setup() {
la = new long[size];
}
public static long f(long x) {
long quadratic = 42 * x * x + 19 * x + 47;
return Long.divideUnsigned(quadratic, x + 1);
}
@Benchmark
public void setAll() {
Arrays.setAll(la, n -> f(n));
}
@Benchmark
public void parallelSetAll() {
Arrays.parallelSetAll(la, n -> f(n));
}
}
JMH2 Benchmark | Size | Score % | Speedup |
---|---|---|---|
setAll | 1 | 0.012 | |
parallelSetAll | 1 | 0.047 | 0.255 |
setAll | 10 | 0.107 | |
parallelSetAll | 10 | 3.894 | 0.027 |
setAll | 100 | 0.990 | |
parallelSetAll | 100 | 3.708 | 0.267 |
setAll | 1000 | 133.814 | |
parallelSetAll | 1000 | 11.747 | 11.391 |
setAll | 10000 | 97.954 | |
parallelSetAll | 10000 | 37.259 | 2.629 |
setAll | 100000 | 988.475 | |
parallelSetAll | 100000 | 276.264 | 3.578 |
setAll | 1000000 | 9203.103 | |
parallelSetAll | 1000000 | 2826.974 | 3.255 |
setAll | 10000000 | 92144.951 | |
parallelSetAll | 10000000 | 28126.202 | 3.276 |
setAll | 100000000 | 921701.863 | |
parallelSetAll | 100000000 | 266750.543 | 3.455 |
setAll | 250000000 | 2299127.273 | |
parallelSetAll | 250000000 | 538173.425 | 4.272 |
可以看到当数组的大小达到
这显然不是从阅读
大多数时候,
剖析和优化
有时你必须检测程序运行时间花在哪儿,从而看是否可以优化那一块的性能。剖析器可以找到这些导致程序慢的地方,因而你可以找到最轻松,最明显的方式加快程序运行速度。
剖析器收集的信息能显示程序哪一部分消耗内存,哪个方法最耗时。一些剖析器甚至能关闭垃圾回收,从而帮助限定内存分配的模式。
剖析器还可以帮助检测程序中的线程死锁。注意剖析和基准测试的区别。剖析关注的是已经运行在真实数据上的整个程序,而基准测试关注的是程序中隔离的片段,通常是去优化算法。
安装
> jvisualvm
运行该命令后会弹出一个窗口,其中包括一些指向帮助信息的链接。
优化准则
- 避免为了性能牺牲代码的可读性。
- 不要独立地看待性能。衡量与带来的收益相比所需投入的工作量。
- 程序的大小很重要。性能优化通常只对运行了长时间的大型项目有价值。性能通常不是小项目的关注点。
- 运行起来程序比一心钻研它的性能具有更高的优先级。一旦你已经有了可工作的程序,如有必要的话,你可以使用剖析器提高它的效率。只有当性能是关键因素时,才需要在设计
/ 开发阶段考虑性能。 - 不要猜测瓶颈发生在哪。运行剖析器,让剖析器告诉你。
- 无论何时有可能的话,显式地设置实例为
null 表明你不再用它。这对垃圾收集器来说是个有用的暗示。 static final 修饰的变量会被 JVM 优化从而提高程序的运行速度。因而程序中的常量应该声明static final 。
风格检测
当你在一个团队中工作时
一个流行的风格检测器是
运行所有风格检测的命令是:
gradlew checkstyleMain
一些文件仍然产生了风格检测警告,通常是因为这些例子展示了你在生产代码中不会使用的样例。
你还可以针对一个具体的章节运行代码检测。例如,下面命令会运行 Annotations 章节的风格检测:
gradlew annotations:checkstyleMain
静态错误分析
尽管
gradlew findbugsMain
这会为每一章生成一个名为
当你查看报告时,你将会看到很多
当我最初看到本书的
代码重审
单元测试能找到明显重要的
代码重审可以作为结对编程的一部分,作为代码签入过程的一部分(另一个程序员自动安排上审查新代码的任务)或使用群组预排的方式,即每个人阅读代码并讨论之。后一种方式对于分享知识和营造代码文化是极其有益的。
结对编程
结对编程是指两个程序员一起编程的实践活动。通常来说,一个人“驱动”(敲击键盘,输入代码
结对编程有很多好处,但最显著的是分享知识和防止阻塞。最佳传递信息的方式之一就是一起解决问题,我已经在很多次研讨会使用了结对编程,都取得了很好的效果(同时,研讨会上的众人可以通过这种方式互相了解对方
维基百科上这篇 结对编程的文章 可以作为你深入了解结对编程的开始。
重构
技术负债是指迭代发展的软件中为了应急而生的丑陋解决方案从而导致设计难以理解,代码难以阅读的部分。特别是当你必须修改和增加新特性的时候,这会造成麻烦。
重构可以矫正技术负债。重构的关键是它能改善代码设计,结构和可读性(因而减少代码负债
很难向管理人员推行重构
重构基石
在开始重构代码之前,你需要有以下三个系统的支撑:
- 测试(通常,
JUnit 测试作为最小的根基) ,因此你能确保重构不会改变代码的行为。 - 自动构建,因而你能轻松地构建代码,运行所有的测试。通过这种方式做些小修改并确保修改不会破坏任何事物是毫不费力的。本书使用的是
Gradle 构建系统,你可以在 代码示例 的build.gradle 文件中查看示例。 - 版本控制,以便你能回退到可工作的代码版本,能够一直记录重构的每一步。
本书的代码托管在 Github 上,使用的是
没有这三个系统的支持,重构几乎是不可能的。确实,没有这些系统,起初维护和增加代码是一个巨大的挑战。令人意外的是,有很多成功的公司竟然在没有这三个系统的情况下在相当长的时间里勉强过得去。然而,对于这样的公司来说,在他们遇到严重的问题之前,这只是个时间问题。
维基百科上的 重构文章 提供了更多的细节。
持续集成
在软件开发的早期,人们只能一次处理一步,所以他们坚信他们总是在经历快乐之旅,每个开发阶段无缝进入下一个。这种错觉经常被称为软件开发中的“瀑布流模型”。很多人告诉我瀑布流是他们的选择方法,好像这是一个选择工具,而不仅是一厢情愿。
在这片童话的土地上,每一步都按照指定的预计时间准时完美结束,然后下一步开始。当最后一步结束时,所有的部件都可以无缝地滑在一起,瞧,一个装载产品诞生了!
当然,现实中没有事能按计划或预计时间运作。相信它应该,然后当它不能时更加相信,只会使整件事变得更糟。否认证据不会产生好的结果。
除此之外,产品本身经常也不是对客户有价值的事物。有时一大堆的特性完全是浪费时间,因为创造出这些特性需求的人不是客户而是其他人。
因为受流水工作线的思路影响,所以每个开发阶段都有自己的团队。上游团队的延期传递到下游团队,当到了需要进行测试和集成的时候,这些团队被指望赶上预期时间,当他们必然做不到时,就认为他们是“差劲的团队成员”。不可能的时间安排和负相关的结合产生了自实现的预期:只有最绝望的开发者才会乐意做这些工作。
另外,商学院培养出的管理人员仍然被训练成只在已有的流程上做一些改动——这些流程都是基于工业时代制造业的想法上。注重培养创造力而不是墨守成规的商学院仍然很稀有。终于一些编程领域的人们再也忍受不了这种情况并开始进行实验。最初一些实验叫做“极限编程”,因为它们与工业时代的思想完全不同。随着实验展示的结果,这些思想开始看起来像是常识。这些实验逐渐形成了如今显而易见的观点——尽管非常小——即把生产可运作的产品交到客户手中,询问他们
这完全颠倒了瀑布流开发的方式。你停止假设你要处理产品测试和把部署
有许多不同的想法导向这种方式,但是目前首要的术语叫持续集成(CI
当前
使用持续集成,每次你合进仓库时,这些改变都会被从头到尾验证。通过这种方式,一旦出现问题你能立即发现。甚至当你准备交付一个产品的新版本时,都不会有延迟或其他必要的额外步骤(在任何时刻都可以交付叫做持续交付
本书的示例代码都是在
本章小结
“它在我的机器上正常工作了
代码校验不是单一的过程或技术。每种方法只能发现特定类型的