阅读1:静态检查
今天的课程有两个主题:
- 静态类型
- 优秀软件的主要三个属性
冰雹序列
首先我们介绍一个例子:冰雹序列。它的定义如下:以数字n开始,如果n为偶数,则下一个数
为n/2,否则n为奇数时,下一个数为3n+1,如此反复直到出现1为止。这里是一些例子:
- 1
- 2
2, 1
3, 10, 5, 16, 8, 4, 2, 1
4, 2, 1
2^n, 2^n-1 , … , 4, 2, 1
5, 16, 8, 4, 2, 1``
7, 22, 11, 34, 17, 52, 26, 13, 40, …
因为奇数的那个规则,序列可能会在结束之前时高时低。大家猜测就像所有的冰雹都会掉落到地面一样,对于所有的n冰雹序列都会以1结束。(这还是个悬而未解的问题)但是为什么叫做冰雹序列呢?这是因为雹块通过在云层中上下运动而形成,直到它们足够重以降落到地面。
计算冰雹序列
这里是计算和打印冰雹序列的代码,用Java和python分别书写用以比较:
// Java
int n = 3;
while (n != 1) {
System.out.println(n);
if (n % 2 == 0) {
n = n / 2;
} else {
n = 3 * n + 1;
}
}
System.out.println(n);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
# Python
n = 3
while n != 1:
print(n)
if n % 2 == 0:
n = n / 2
else:
n = 3 * n + 1
print(n)
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
这里有需要注意一些东西:
- Java和python中表达和陈述的基本含义是十分类似的,比如说while和if 有相同的作用。
- Java需要在语句的结尾加上分号。额外的标点是一种痛苦,但是他也在怎样组织代码上给了你更多的自由(你可以为了可读性将一个句子分成若干行)。
- Java需要在if和while的条件处添加圆括号
- Java在代码块前后使用花括号而不是象python一样使用空白。尽管Java不会在意你的额外的空白,你还是需要始终缩进块。编程是交流的一种形式,并且你不仅是在与编译器交流,而且是在和人类交流。人类需要那个空白来方便浏览,我们等会再来看这方面。
数据类型
python和Java之间最重要语义上的差别在于变量n的定义——一个int 类型的变量。
一个数据类型是一系列数值以及这些数值上的操作。
Java有以下的几个原语类型:
-
int(象5和-200这样的整数,但是必须在 ± 2^31之间,或者大概的说是在± 20亿之间)
-
long(对于更大的整数至± 2^63)
-
boolean(真和假)
-
double(用于浮点类数值,表示实数的子集)
-
char(用于单个的字母象’A’和’$’)
Java也有对象类型: -
String代表字母的集合,例如一个Python语句。
-
BigInteger代表任意大小的整数,所以它和Python的整数有着相同的含义。
按照Java的惯例,原语类型是小写的,而对象类型以大写字母开头。
操作符有着接受输入并且产生输出的功能(有时是改变那些数值本身)。它们的句法各有差别,但是不管他们是怎么写的,我们任然认为它们是函数。下面是Java或Python中三种不同的书写方式:
- 作为中缀、前缀或后缀运算符。例如,a+b调用操作
+:int * int -> int
(+
是这个映射的名字,箭头之前的int * int
描述了两个输入,箭头之后的int
描述了输出)。 - 作为对象的方法。例如,
bigint1.add(bigint2)
代表了操作add:
BigInteger * BigInteger -> BigInteger
- 作为一个函数:例如,
Math.sin(theta)
代表操作sin : double -> double
。这里Math
不是一个对象,而是包含sin
这个函数的类。
比较Java’sstr.length()
和Python‘s len(str)
。两者具有相同的含义——输入一个字符串然后返回它的长度,但是却运用了不同的句法。
一些操作是可以重载的,因为相同的操作名称用于不同的类型。Java中算术运算符+
,-
,*
,/
都是可重载的。方法也可以重载。大部分的编程语言都可以一定程度的重载。
静态类型
Java是一种静态类型的语言。在编译阶段(程序运行之前)所有的变量的类型都是已知的,因此编译器也可以推断所有表达式的类型。如果a
和b
是int
类型的,那么编译器就可以知道a+b
是int
类型的。Eclipse编译环境在你写代码的时候就已经在做这个事了。事实上,你可以在编程的时候就发现许多问题。
在像Python这样的动态类型的编程语言中,这种类型的检查是被推迟直到运行时才做的。
静态类型是特殊类型的静态检查,这意味着在编译时检查bug。bug是编程的祸根。这个课程中的许多例子都旨在从你的代码中除去bug,并且静态检查是我们知道的第一个方法。静态检查避免了许多影响你程序的bug:准确的说,是由于将数据类型用在了错误的操作符上。如果你写了这样一行不完整的代码:
"5" * "6"
- 1
对两个字符串进行乘法操作,然后静态检查就会在你还在编程的时候提示你这个错误,而不是等到这一行被执行的时候才说。
静态检查,动态检查,无检查
认真思考一下编程语言能够提供的三种自动检查是十分有用的:
-
静态检查
:bug在程序还没有被执行的时候被自动地检查出来。 -
动态检查
:bug在程序正在被执行的时候被发现 -
无检查:编程语言根本不帮助你找到bug。你必须自己认真检查,不然就会最终得到错误的程序。
毫无疑问,静态捕获bug比动态捕获它要好,而动态捕获比根本不捕获它要好。
下面是一些各种类型的检查能检查出来的错误:
静态检查: -
语法错误,例如多余的标点符号或者是错误的关键词。动态类型的语言像Python也会做这种类型错误的检查。如果你在你的Python程序中有一个多余的缩进,你会在程序执行之前发现出错。(编译不会通过)
-
错误的名字,例如
Math.sine(2)
.(正确的应该是sin
) -
错误的参数个数,例如
Math.sin(30,20)
-
错误的参数形式,例如
Math.sin("30")
-
错误的返回类型,例如从一个应该返回
int
类型的函数中return "30"
。
动态检查: -
非法变量值:例如,表达式
x/y
只有当y
为0的时候是错误的;否则他都是合法的。因此,在这个表达式中除以0不是一个静态错误而是一个动态错误. -
无法表示的返回值,例如最后得到的返回值无法用声明的类型来表示。
-
越界访问,例如在字符串中使用负数或者是太大的索引。
-
对
null
对象引用调用方法
静态检查倾向于类型错误,这些错误与变量的特定值无关。类型是一组值。静态类型可以保证变量将从该集合中得到一些值,但是我们直到运行时才知道它到底有哪些值。因此,如果错误只会由某些值引起,比如除以零或索引超出范围,那么编译器就不会产生关于它的静态错误。
相比之下,动态检查往往是由特定值引起的错误。
原语类型不是真数
Java(许多其他的编程语言也一样)中的一个陷阱是它的原始数值类型的对象并不像我们熟悉的整数或者实数那样得到应有的输出。导致一些本应该被动态检查出来的问题没有被发现,下面是一些例子:
- 整数除法:
5/2
不返回一个分数,而是返回一个被截断了的整数。所以这就是我们以为会出现动态错误的地方却没有被发现(因为分数不能表示为整数 - 整数溢出:int 类型和long类型都是整数的有限子集,都有它们的范围。当你做一个运算的结果太正或者太负,不适合那个有限的范围会怎样的呢?计算悄然溢出,并且从那个有限的范围中返回一个不正确的答案。
- 浮点类型中的特殊值:浮点类型像
double
类型有一些不是真实的数字的特殊值:NaN
(“Not a Number”),POSITIVE_INFINITY
, andNEGATIVE_INFINIT
。所以当你做浮点类型的数的计算时,你的错误像除零或者是给负数开平方根,不会被检查出来,而是会返回一个特别的答案。
下面是习题,但是我那个网页显示不出来就不做翻译。
数组和集合
让我们更改冰雹序列的计算方法,以便将序列存储在一种数据类型中,而不是只将它打印出来。Java有两种我们能够使用的列表类型的数据类型:数组和列表
数组是另一种类型的固定长度的序列,例如,下面是如何声明一个数组变量。
int[] a = new int[100];
- 1
int[ ]
包括所有长度的数组,但是一个特定数组,一旦被创建,它的长度就确定了。数组类型上的操作包括:
- 索引其中一个元素:
a[2]
- 为其中一个元素赋值:
a[2]=0
- 查看数组长度:
a.length
(与String.length()
不同,a.length
不是一个方法调用,因此在此之后不用括号)
下面是利用数组写的冰雹代码,它存在一些bug。
int[] a = new int[100]; // <==== DANGER WILL ROBINSON
int i = 0;
int n = 3;
while (n != 1) {
a[i] = n;
i++; // very common shorthand for i=i+1
if (n % 2 == 0) {
n = n / 2;
} else {
n = 3 * n + 1;
}
}
a[i] = n;
i++;
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
在这个例子中,我们可以发现有些东西不太对劲,为啥数组的长度是100(100称为幻数)?如果我们尝试的n的冰雹序列非常长会怎么样?长到100长度的数组不合适。万一我们犯了错误,Java是否能够静态地、动态地检查出这个错误或者根本不检查?偶然地,像这样一个固定长度的数组的溢出在像C或者C++这样不太安全的语言中是非常常见的,而且被称为缓冲区溢出。这种溢出是大量网络安全漏洞和网络爬虫的罪魁祸首。
试试List
类型而不是固定长度的数组,List类型是不定长度的,下面看我们如何声明一个List类型的变量:
List<Integer> list = new ArrayList<Integer>();
- 1
下面是List类型的一些操作:
- 查看任意元素的数值:
list.get(2)
- 修改任意元素的值:
list.set(2,0)
- 获得List的长度:
list.size()
这里要注意List
是一个接口,不能够直接用new
来构造,必须用能够实现List要求满足的操作符的方法来构造。我们将在未来的抽象数据型课程中讲到这点。ArrayList
是一个类,是提供这些功能的具体类型。ArrayList不是唯一的提供者,但是是最常用的一个。在Java API文件中均可查到。
注意我们写的是List< Integer >而不是List< int>,不幸的是我们不能直接模仿int[]那样写List< int>。List只知道如何处理对象类型,而不知道原始类型。在Java中,每个原语类型(它们是用小写写的,通常是缩写的,比如int
)都有一个等效的对象类型(这个对象类型是大写的,并且拼写完整,就像Integer
一样)。Java要求我们在参数化带有尖括号的类型时使用这些对象类型等价物。但是在其他情况下,Java会自动在int
和Integer
之间进行转换,因此我们可以编写Integer i=5
,而不会出现任何类型错误。
下面是用列表编写的冰雹代码:
List<Integer> list = new ArrayList<Integer>();
int n = 3;
while (n != 1) {
list.add(n);
if (n % 2 == 0) {
n = n / 2;
} else {
n = 3 * n + 1;
}
}
list.add(n);
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
迭代
for循环遍历数组或列表的元素,就像Python中的那样,尽管语法看起来有点不同。例如:
// find the maximum point of a hailstone sequence stored in list
int max = 0;
for (int x : list) {
max = Math.max(x, max);
}
- 1
- 2
- 3
- 4
- 5
你可以遍历数组和列表。如果将列表替换为数组,则相同的代码将工作。Math.max()
是JavaAPI中的一个函数。Math类中有很多有用的函数。
方法
在Java中,语句在方法中,每个方法在类中,所以编写冰雹算法最简单的方式是:
public class Hailstone {
/**
* Compute a hailstone sequence.
* @param n Starting number for sequence. Assumes n > 0.
* @return hailstone sequence starting with n and ending with 1.
*/
public static List<Integer> hailstoneSequence(int n) {
List<Integer> list = new ArrayList<Integer>();
while (n != 1) {
list.add(n);
if (n % 2 == 0) {
n = n / 2;
} else {
n = 3 * n + 1;
}
}
list.add(n);
return list;
}
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
在这里我们解释一些新东西。public
意味着程序中任何地方的任何代码都可以引用这个类或这个类中的方法。其他访问修饰符(如private
)用于在程序中获得更多的安全性。并确保不可变类型的不可变性。我们将在下一堂课上更多地谈论它们。static
意味着该方法是一个不接受Self
参数的函数(在Java中它是一个名为this
的隐式参数,你永远不会将它看作一个方法参数)。静态的方法不能通过对象来调用,例如List
add()方法
或者 String
length()
方法,它们要求先有一个对象。静态方法的正确调用应该使用类来索引,例如:
Hailstone.hailstoneSequence(83)
- 1
另外,记得在定义的方法前面写上注释。这些注释应该描述了这个方法的功能,输入输出/返回,以及注意事项。记住注释不要写的啰嗦,而是应该直切要点,简洁明了。例如在上面的代码中,n是一个整型的变量,这个在声明的时候int已经体现出来了,就不需要进行注释。但是如果我们设想的本意是n不能为负数,而这个编译器(声明)是不能检查和体现出来的,我们就应该注释出来,方便阅读理解和修改。
这些东西我们会在后面的课程中详细介绍,但是你现在就要开始试着正确使用他们。
变化的值 vs. 可被赋值的改变
在下一篇阅读资料中我们会介绍“快照图”(snapshot diagrams),以此来辨别修改一个变量和修改一个值的区别。当你给一个变量赋值的时候,你实际上是在改变这个变量指向的对象(值也不一样)。
而当你对一个可变的值进行赋值操作的时候——例如数组或者列表——你实际上是在改变对象本身的内容。
变化是“邪恶”的,好的程序员会避免可改变的东西,因为这些改变可能是意料之外的。
不变性(Immutability)是我们这门课程的一个重要设计原则。不变类型是指那些这种类型的对象一旦创建其内容就不能被更改的类型(至少外部看起来是这样,我们在后面的的课程中会说一些替代方案)。思考一下在上面的代码中哪一些类型是可更改类型,哪一些不是?
Java也给我们提供了不变的索引:只要变量被初始化后就不能再次被赋值了——只要在声明的时候加上final
:
final int n = 5;
- 1
如果编译器发现你的
final
变量不只是在初始化的时候被“赋值”,那么它就会报错。换句话说,final
会提供不变索引的静态检查。
正确的使用final
是一个好习惯,就好像类型声明一样,这不仅会让编译器帮助你做静态检查,同时别人读起来也会更顺利一些。
在hailstoneSequence
方法中有两个变量n和list,我们可以将它们声明为final吗?请说明理由。
public static List<Integer> hailstoneSequence(final int n) {
final List<Integer> list = new ArrayList<Integer>();
- 1
- 2
- 3
记录你的设想
在文档中写下变量的类型记录了一个关于它的设想, 例如这个变量总是指向一个整型. 在编译的时候 Java 就会检查这个设想, 并且保证在你的代码中没有任何一处违背这个设想。
而使用final
关键字去定义一个变量也是一种记录设想, 要求这个变量在其被赋值之后就永远不会再被修改, Java 也会对其进行静态地检查。
不幸的是 Java 并不会自动检查所有设想,例如:n 必须为正数。
为什么我们需要写下我们的设想呢? 因为编程就是不断的设想, 如果我们不写下他们, 就可能会遗忘掉他们, 而且如果以后别人想要阅读或者修改我们的软件, 他们就会很难理解代码, 不得不去猜测。
所以在编程的时候我们必须朝着如下两个目标努力:
- 和电脑通讯。首先,说服编译器你的程序是合理的-语法正确和类型正确.然后使逻辑正确,以便在运行时给出正确的结果。
- 和其他人交流。使程序易于理解,以便当某人必须修复它,改进它,或在未来调整它,他们可以这样做。
Hacking vs. Engineering
我们在这门课中经常写一些黑客性质的代码。他们的特点就是肆无忌惮的乐观:
缺点有:
-
在测试其中任何一段代码之前写许多代码
-
把细节都留在自己的脑子里,以为自己能够永远记得,而不是把他们都写进代码中
-
大意地认为错误都十分简单并且容易被发现
但是软件工程不是黑客性质的,他们是悲观主义者:
优点有: -
每次只写一点,然后就测序。
-
记录代码所依赖的假设
-
保护代码免受愚蠢的错误-尤其是自己犯的!静态检查会有助于你免于那个
总结
我们今天的主题是静态检查,下面是这一主题与这门课的关联:
- 帮助我们远离bug,
- 易于阅读
- 易改动:静态检查会在你改写代码的同时检查出你与此同时犯的一些错误。例如,当你更改变量的名称或类型时,编译器会立即在使用该变量的所有位置显示错误,并提醒你更正这些错误。