注释:全文翻译
阅读1:静态检查
目标
今天的课程有两个主题:
- 静态打字
- 好软件的三大特性
冰雹序列
作为一个运行示例,我们将探索冰雹序列,其定义如下。从一个数字开始n序列中的下一个数字是n/2如果n是偶数,还是3n+1如果n很奇怪。该序列在达到1时结束。以下是一些例子:
2, 1 3, 10, 5, 16, 8, 4, 2, 1 4, 2, 1 2n, 2n-1 , … , 4, 2, 1 5, 16, 8, 4, 2, 1 7, 22, 11, 34, 17, 52, 26, 13, 40, …? (where does this stop?)
由于奇数规则,该序列在减少到1之前可能会上下波动。据推测,所有冰雹最终都会落到地面,即所有开始的冰雹序列都达到1n-但那仍然是一个容易讨论的问题。为什么叫冰雹序列?因为冰雹在云中通过上下跳动形成,直到它们最终积累足够的重量落到地球上。
计算冰雹
下面是一些计算和打印冰雹序列的代码n。我们将并排编写Java和Python进行比较:
| |
这里有几件事值得注意:
- Java中表达式和语句的基本语义与Python非常相似:
while
和if
例如,行为相同。 - Java要求在语句末尾使用分号。额外的标点符号可能是一种痛苦,但它也给了你更多的自由来组织你的代码——你总是可以将一个语句分成多行以增加可读性。
- Java要求用括号将条件括起来
if
和while
. - Java在块周围使用花括号,而不是缩进。你应该总是缩进块,即使Java不会注意你的额外空间。编程是一种交流方式,你不仅是在和编译器交流,也是在和人类交流。人类需要这种压痕。我们稍后将回到这一点。
类型
上面的Python和Java代码之间最重要的语义差异是变量的声明n
,它指定其类型:int
.
A 类型是一组值,以及可以对这些值执行的操作。
Java有几个原始类型,其中包括:
int
(对于像5和-200这样的整数,但是限制在大约2的范围内31,或大约20亿)long
(对于高达约2的较大整数63)boolean
(判断对错)double
(对于浮点数,它代表实数的子集)char
(对于单个字符,如'A'
和'$'
)
Java也有对象类型,例如:
String
表示字符序列,就像Python字符串一样。BigInteger
表示任意大小的整数,因此其行为类似于Python整数。
按照Java惯例,基本类型是小写的,而对象类型以大写字母开头。
操作是接受输入并产生输出的函数(有时会改变值本身)。操作的语法各不相同,但是不管它们是如何编写的,我们仍然把它们看作是函数。以下是Python或Java操作的三种不同语法:
- 作为中缀、前缀或后缀运算符。举个例子,
a + b
调用操作+ : int × int → int
(在这个符号中:+
是操作的名称,int × int
在箭头描述两个输入之前,和int
在箭头描述输出之后。) - 作为对象的方法。举个例子,
bigint1.add(bigint2)
调用操作add: BigInteger × BigInteger → BigInteger
. - 作为一种功能。举个例子,
Math.sin(theta)
调用操作sin: double → double
。这里,Math
不是一个对象。这个类包含了sin
功能。
对比Java的str.length()
用Python的len(str)
。这是两种语言中相同的操作——一个接受字符串并返回其长度的函数——但它使用了不同的语法。
一些操作是超载的因为不同的类型使用相同的操作名称。算术运算符+
, -
, *
, /
对于Java中的数字基元类型来说是严重重载的。方法也可以被重载。大多数编程语言都有某种程度的重载。
静态打字
Java是一种静态类型的语言。所有变量的类型在编译时(程序运行前)是已知的,因此编译器也可以推导出所有表达式的类型。如果a
和b
被声明为int
s,那么编译器得出结论a+b
也是一个int
。事实上,Eclipse环境在您编写代码的时候就这样做了,所以您在打字的时候会发现许多错误。
在…里动态类型化语言像Python一样,这种检查被推迟到运行时(当程序运行时)。
静态类型是一种特殊的静态检查,这意味着在编译时检查错误。bug是编程的祸根。本课程中的许多想法都旨在消除代码中的错误,静态检查是我们看到的第一个想法。静态类型可以防止一大类错误感染你的程序:准确地说,是对错误类型的参数应用操作而导致的错误。如果你写了一行像这样的代码:
"5" * "6"
试图将两个字符串相乘,那么静态类型将在您仍在编程时捕捉到这个错误,而不是等到执行期间到达该行。
静态检查、动态检查、无检查
考虑一种语言可以提供的三种自动检查是很有用的:
- 静态检查:甚至在程序运行之前,错误就被自动发现了。
- 动态检查:执行代码时会自动发现bug。
- 不检查:语言根本不能帮你找到错误。你必须自己注意,否则最终会得到错误的答案。
不用说,静态捕捉一个bug比动态捕捉好,动态捕捉比完全不捕捉好。
下面是一些经验法则,告诉你在这些时候可能会遇到什么样的错误。
静态检查可以接住:
- 语法错误,如多余的标点符号或伪造的单词。甚至像Python这样的动态类型语言也做这种静态检查。如果您的Python程序中有缩进错误,您会在程序开始运行之前发现。
- 错误的名字,比如
Math.sine(2)
。(正确的名称是sin
.) - 错误的参数数量,例如
Math.sin(30, 20)
. - 错误的参数类型,如
Math.sin("30")
. - 错误的返回类型,如
return "30";
从一个声明返回int
.
动态检查可以接住:
- 非法参数值。例如,整数表达式
x/y
只有在以下情况下才是错误的y
实际上是零。否则是有效的。所以在这个表达式中,被零除不是静态误差,而是动态误差。 - 不可表示的返回值,即当特定的返回值不能在类型中表示时。例如,试图创建Java日期“2100年2月29日”是一个动态错误(2100年不是闰年)。
- 超出范围的索引,例如,对字符串使用负的或太大的索引。
- 在上调用方法
null
对象引用(null
就像Python一样None
).
静态检查往往是关于类型错误与变量的具体值无关的误差。类型是一组值。静态类型保证变量将具有一些值,但是直到运行时我们才知道它到底有哪个值。因此,如果错误仅仅是由某些值引起的,比如被零除或索引超出范围,那么编译器就不会引发静态错误。
相比之下,动态检查倾向于由特定值引起的错误。
惊奇:原始类型不是真正的数字
Java和许多其他编程语言中的一个陷阱是,它的原始数字类型有极限情况,其行为不像我们习惯的整数和实数。结果,一些真正应该被动态检查的错误根本没有被检查。以下是陷阱:
-
整数除法.
5/2
不返回分数,而是返回截断的整数。因此,这是一个例子,说明我们可能希望的动态错误(因为分数不能用整数表示)经常会产生错误的答案。 -
整数溢出。这
int
和long
类型实际上是整数的有限集,有最大值和最小值。当你做一个计算,而它的答案太正或太负而不适合那个有限的范围时,会发生什么?悄悄的计算充满(换行),并返回合法范围内的某个整数,但不是正确答案。 -
浮点类型中的特殊值。浮点类型,如
double
有几个非实数的特殊值:NaN
(代表“不是数字”),POSITIVE_INFINITY
,以及NEGATIVE_INFINITY
。因此,当您将某些操作应用到double
你预期会产生动态误差,比如除以零或者取负数的平方根,你会得到这些特殊值中的一个。如果你继续用它计算,你会得到一个糟糕的最终答案。
阅读练习
让我们尝试一些错误代码的例子,看看它们在Java中的表现。这些bug是静态捕获的,动态捕获的,还是根本没有捕获的?
1
2
3
4
5
数组和集合
让我们改变我们的hailstone计算,使它将序列存储在一个数据结构中,而不是仅仅打印出来。Java有两种我们可以使用的类似列表的类型:数组和列表。
数组是另一种类型t的定长序列。例如,下面是如何声明一个数组变量并构造一个数组值来赋给它:
int[] a = new int[100];
这int[]
数组类型包括所有可能的数组值,但是一个特定的数组值一旦创建,就不能改变它的长度。对数组类型的操作包括:
- 索引:
a[2]
- 任务:
a[2]=0
- 长度:
a.length
(请注意,这与的语法不同String.length()
因为a.length
不是方法调用,你不要在它后面放括号)
这是使用数组破解冰雹代码的方法。我们从构造数组开始,然后使用一个索引变量i
遍历数组,在生成序列值时存储它们。
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++;
在这种方法中,应该立即察觉到一些问题。那个神奇的数字100是什么?如果我们尝试一个n
结果发现有很长的冰雹序列。它不适合长度为100的数组。我们有一个bug。Java会静态地、动态地或者根本不捕捉bug吗?顺便提一下,像这样的错误——溢出固定长度的数组,这通常用在不太安全的语言中,如C和C++,它们不进行数组访问的自动运行时检查——被称为缓冲区溢出,并对大量网络安全漏洞和互联网蠕虫负责。
我们不使用固定长度的数组,而是使用List
类型。列表是另一种类型的可变长度序列T
。下面是我们如何声明一个List
变量并制作一个列表值:
List<Integer> list = new ArrayList<Integer>();
这是它的一些操作:
- 索引:
list.get(2)
- 任务:
list.set(2, 0)
- 长度:
list.size()
为什么List
在左边但是ArrayList
在右边?List
是一个接口,是一种不能直接构造的类型,但它指定List
必须提供。我们会在以后的课上讨论这个概念抽象数据类型. ArrayList
是一个类,一个提供这些操作实现的具体类型。ArrayList
不是唯一的List
类型,尽管它是最常用的一种。LinkedList
是另一个。你可以看到所有的操作List
或…的细节ArrayList
或者LinkedList
在Java API文档中:通过web搜索“Java 12 API”找到它了解Java API文档,它们是你的朋友。(“API”的意思是“应用编程接口”,这里指的是Java提供的帮助你构建Java应用的方法和类。)
为什么List<Integer>
而不是List<int>
?不幸的是,我们不会写List<int>
直接模拟int[]
。列表只知道如何处理对象类型,不知道如何处理基本类型。每个原始类型(用小写字母书写,通常缩写,如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);
不仅更简单,而且也更安全,因为列表会自动放大,以适应您添加的数字(当然,直到您用完内存)。
重复
A for
循环遍历数组或List
,就像在Python中一样,尽管语法看起来有点不同。例如:
// find the maximum point of a hailstone sequence stored in list
int max = 0;
for (int x : list) {
max = Math.max(x, max);
}
你可以遍历数组和列表。如果用数组替换列表,同样的代码也可以工作。
Math.max()
是Java API中一个方便的函数。这Math
类中充满了像这样有用的函数——web搜索“Java 12 Math”来找到它的文档。
方法
在Java中,语句通常必须在一个方法中,并且每个方法都必须在一个类中,所以编写hailstone程序的最简单方法如下:
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;
}
}
让我们在这里解释几个新的东西。
public
意味着程序中任何地方的任何代码都可以引用该类或方法。其他访问修饰符,如private
,用于在程序中获得更多的安全性,并保证不可变类型的不变性。我们将在接下来的课程中更多地讨论它们。
static
意味着该方法是一个不采用self
参数(在Java中是一个隐式参数,名为this
你永远不会看到它是一个方法参数)。静态方法不会在对象上调用。相比之下List
add()
方法或String
length()
方法,例如,它要求一个对象在前面。相反,调用静态方法的正确方法是使用类名而不是对象引用:
Hailstone.hailstoneSequence(83)
还要注意方法前的注释,因为它非常重要。该注释是方法的规范,描述了操作的输入和输出。规范应该简洁、清晰、准确。注释提供了方法类型中尚不清楚的信息。例如,它没有说n
是一个整数,因为int n
下面的声明已经说了。但是它确实说n
必须是正数,类型声明不会捕捉到这一点,但调用方知道这一点非常重要。
关于如何编写好的规格说明书,我们将在几节课中有更多的内容要讲,但是你必须马上开始阅读和使用它们。
改变值与重新分配变量
下一次阅读将会介绍快照图表为我们提供了一种可视化改变变量和改变值之间区别的方法。当你给一个变量赋值时,你改变了变量的箭头指向。您可以将它指向不同的值。
当你给一个可变值的内容赋值时——比如一个数组或列表——你在改变这个值内部的引用。
改变是不可避免的罪恶。优秀的程序员会避免变化的东西,因为它们可能会出乎意料地发生变化。
不变性(免于变化)是本课程的主要设计原则。不可变类型的值一旦被创建就永远不能改变。(至少不是以外界可见的方式——这里有一些微妙之处,我们将在未来关于不变性的课程中详细讨论。)到目前为止,我们讨论的哪些类型是不可变的,哪些是可变的?
Java也给了我们不可变的引用:被赋值一次的变量永远不会被重新赋值。若要使引用不可重新赋值,请用关键字声明它final
:
final int n = 5;
如果Java编译器不相信您的final
变量在运行时只被赋值一次,那么它将产生一个编译器错误。因此final
为您提供不可赋值引用的静态检查。
使用是很好的实践final
用于声明方法的参数和尽可能多的局部变量。像变量的类型一样,这些声明是重要的文档,对代码的读者很有用,并且由编译器进行静态检查。
在我们的hailstoneSequence方法中有两个变量:我们可以声明它们吗final
,还是没有?
public static List<Integer> hailstoneSequence(final int n) {
final List<Integer> list = new ArrayList<Integer>();
// ...
记录假设
写下变量的类型记录了关于它的一个假设:例如,这个变量总是指一个整数。Java实际上会在编译时检查这个假设,并保证你的程序中没有任何地方违反了这个假设。
声明变量final
也是一种文档形式,声明变量在初始赋值后永远不会改变。Java也静态地检查这一点。
我们记录了另一个假设,即Java(不幸的是)不会自动检查n
必须是正面的。
为什么我们需要写下我们的假设?因为编程中充满了它们,如果我们不把它们写下来,我们就不会记住它们,其他需要阅读或稍后更改我们程序的人也不会知道它们。他们不得不猜测。
编写程序时必须牢记两个目标:
- 与计算机通信。首先说服编译器你的程序是合理的——语法正确,类型正确。然后获得正确的逻辑,以便在运行时给出正确的结果。
- 与其他人交流。让程序变得容易理解,这样当将来有人需要修复它、改进它或修改它时,他们就可以这样做。
黑客与工程
在这次阅读中,我们写了一些代码。黑客通常以肆无忌惮的乐观主义为标志:
- 不好:在测试之前写很多代码
- 不好:把所有的细节都记在脑子里,假设你会永远记住它们,而不是把它们写在代码里
- 坏:假设错误不存在,或者很容易被发现和修复
但是软件工程不是黑客。工程师是悲观主义者:
- 好:一次写一点,边写边测试。在未来的课程中,我们将讨论测试优先编程。
- 好:记录代码所依赖的假设
- 好:保护你的代码免受愚蠢——尤其是你自己的代码!静态检查对此有所帮助。
阅读练习
考虑以下简单的Python函数:
from math import sqrt
def funFactAbout(person):
if sqrt(person.age) == int(sqrt(person.age)):
print("The age of " + person.name + " is a perfect square: " + str(person.age))
假设
可检验的假设
6.031的目标
我们在本课程中的主要目标是学习如何制作具有以下特点的软件:
- 免受虫子的侵害。正确性(现在的正确行为)和防御性(未来的正确行为)是我们构建的任何软件都需要的。
- 容易理解。代码必须与未来的程序员交流,他们需要理解代码并对其进行修改(修复错误或添加新功能)。未来的程序员可能是你,几个月或几年后。你会惊讶,如果你不写下来,你会忘记多少,这对你自己未来的自己有一个好的设计有多大的帮助。
- 准备好改变了吗。软件总是会变的。有些设计很容易改变;其他的需要扔掉和重写大量的代码。
软件还有其他重要的属性(如性能、可用性、安全性),它们可能会在这三者之间进行权衡。但这是我们在6.031中关心的三大问题,也是软件开发人员在构建软件的实践中通常放在首位的。值得考虑我们在本课程中学习的每一种语言特性、每一种编程实践、每一种设计模式,并理解它们与这三者的关系。
阅读练习
功能块
乙撑硫脲(Ethylenethiourea)
请求评论
为什么我们在本课程中使用Java
既然你已经有了6.009,我们假设你已经熟悉Python了。那么我们为什么不在这门课中使用Python呢?6.031中我们为什么要用Java?
安全是第一个原因。Java有静态检查(主要是类型检查,但也有其他类型的静态检查,比如您的代码从声明这样做的方法返回值)。我们在这门课程中学习软件工程,而安全防范错误是这种方法的一个关键原则。Java的安全等级高达11,这使得它成为学习良好软件工程实践的好语言。用像Python这样的动态语言编写安全的代码当然是可能的,但是如果你学会了如何用一种安全的、静态检查的语言,就更容易理解你需要做什么。
到处存在是另一个原因。Java广泛应用于研究、教育和工业领域。Java运行在很多平台上,不仅仅是Windows/Mac/Linux。Java可以用于web编程(服务器端和客户端都可以),原生Android编程是用Java完成的。尽管其他编程语言更适合于编程教学(比如Scheme和ML),但遗憾的是这些语言在现实世界中并不普遍。简历上的Java会被认为是一项有市场的技能。但是不要误解我们的意思:你将从这门课程中获得的真正技能并不是特定于Java的,而是适用于你可能用来编程的任何语言。这门课最重要的教训将在语言时尚中幸存下来:安全、清晰、抽象、工程本能。
无论如何,一个好的程序员必须是使用多种语言的。编程语言是工具,你必须使用正确的工具。在你结束麻省理工学院的职业生涯之前,你肯定会学习其他编程语言(JavaScript、C/C++、Scheme、Ruby、ML或Haskell),所以我们现在开始学习第二种语言。
由于它的无处不在,Java有许多有趣和有用的特性图书馆(包括其巨大的内置库,以及网上的其他库),而且非常免费工具用于开发(ide,如Eclipse、编辑器、编译器、测试框架、分析器、代码覆盖率、样式检查器)。即使是Python,在生态系统的丰富性上,也仍然落后于Java。
有一些后悔使用Java的理由。很罗嗦,很难在黑板上写例子。它很大,多年来积累了许多特性。内部不一致(例如final
关键字在不同的上下文中有不同的含义,而static
Java中的关键字与静态检查无关)。它背负着C/C++等旧语言的包袱(原始类型和switch
语句就是很好的例子)。它没有像Python那样的解释器,你可以通过玩小代码来学习。
但总的来说,Java是目前学习如何编写没有错误、易于理解并随时准备改变的代码的合理选择。这是我们的目标。
摘要
我们今天介绍的主要观点是静态检查。以下是这个想法与课程目标的关系:
-
远离虫子。静态检查通过在运行前捕捉类型错误和其他bug来帮助提高安全性。
-
很好理解。这有助于理解,因为类型在代码中是显式声明的。
-
准备好改变了。静态检查通过识别其他需要修改的地方,使得修改代码变得更加容易。例如,当您更改变量的名称或类型时,编译器会立即在使用该变量的所有地方显示错误,提醒您也要更新它们。