目标:
• 学习静态系列
• 了解好的软件的三大特性
冰雹序列
“冰雹序列”的定义:从正整数n开始,如果n是偶数,则下一个数是n/2,否则下一个数是3n+1,直到n等于1。
这里有几个例子:
由于存在3n+1这种变化,所以序列元素的大小可能会忽高忽低——这也是“冰雹序列”名称的来历,冰雹在落地前会在云层中忽上忽下。那么所有的序列都会最终“落地”变到1吗?(这个猜想称为考拉兹猜想。)
计算冰雹序列
下面的代码用于打印冰雹序列:
在语法中Java和Python的区别:
• Java中表达式和语句的基本语义与Python非常相似:例如while,if行为相同。
• Java需要以分号结尾。额外的标点符号看着很麻烦,实际上它也提供了更多组织代码的自由——例如可以将语句分成多行。
• Java在使用 if 和 while的时需要用括号括起来。
• Java需要在块周围使用花括号。即使Java不会对多余的空间给予任何关注,也应始终缩进该块。编程是一种交流形式,您不仅在与编译器交流,还与人类交流。
数据类型
Python和Java最大的不同就是Java需要指定变量n的类型:int类型是一些值的集合,以及这些值对应的操作。
例如下面这5种常用的原始类型 :
int (例如5和-200这样的整数,但是其范围有限制但在2^31之间,大概在±20亿)
long (比int更大范围的整数,大概至2^63)
boolean(true和false)
double (浮点数,其表示的是实数的子集)
char (单个字符例如 ‘A’ 和 ‘$’)
Java也有对象类型 ,例如:
String 表示一串连续的字符。
BigInteger 表示任意大小的整数。
从Java的传统来说,原始类型用小写字母,对象类型的起始字母用大写。
操作符是一些能接受输入并输出结果的功能。他们的语法各有区别,Java中常见的有下面这三种:
•前缀、中缀、后缀操作符. 例如, a + b 调用这样一种操作(映射) + : int × int → int( + 是这个操作符的名字, int × int 描述了这两个输入, 最后的 int 描述的了输出)
• 一个对象的方法. 例如, bigint1.add(bigint2) 调用这样一种操作(映射) add: BigInteger × BigInteger → BigInteger.
• 一个函数. 例如: Math.sin(theta) 调用这样一种操作(映射) sin: double → double. 注意, Math 不是一个对象,它是一个包含sin函数的类。
有一些操作符可以对不同类型的对象进行操作,这时我们就称之为可重载 (overloaded),例如Java中的算术运算符 +, -, *, / 都是可重载的。一些函数也是可重载的。大多数编程语言都有不容程度的重载性。
静态类型
Java是一种静态类型的语言。所有变量的类型在编译的时候就已经知道了(程序还没有运行),所以编译器也可以推测出每一个表达式的类型。
例如,如果a和b是int类型的,那么编译器就可以知道a+b的结果也是int类型的。事实上,Eclipse编译环境在写代码时如果出错就会发现。
在 动态类型 语言中(例如Python)中,这种类型检查是发生在程序运行时。
静态类型 是静态检查的其中一种——这意味着在编译时检查bug。静态检查是为了避免bug的发生。其中静态类型阻止了一大部分和类型相关的bug—确切点说,就是将操作符用到了不对应的类型对象上。例如,如果你进行下面这个操作:
那么静态类型检查就会在你编辑代码的时候发现这个bug,而不是等到你编译后运行程序的时候告知你。
静态检查、动态检查、无检查
编程语言通常能提供以下三种自动检查的方法:
• 静态检查:bug在程序还没有被执行的时候被自动地检查出来
• 动态检查:bug在程序正在被执行的时候被发现
• 无检查: 编程语言本身不帮助你发现错误,你必须通过特定的条件(例如输出的结果)检查代码的正确性。
相比较而言,静态检查好于动态检查好于不检查。
下面是一些各种类型的检查能检查出来的错误:
静态检查:
•语法错误:例如多余的标点符号或者错误的关键词。即使在动态类型的语言例如Python中也会做这种检查:如果你有一个多余的缩进,在运行之前就能发现它。
• 错误的名字:例如Math.sine(2). (修正:应该是 sin.)
• 错误的参数个数:例如 Math.sin(30, 20).
• 错误的参数类型: Math.sin(“30”).
• 错误的返回类型 :例如一个声明返回int类型函数return “30”;
动态检查:
• 非法的变量值:例如整型变量x、y,表达式x/y 只有在运行后y为0才会报错,否则就是正确的。因此,在这个表达式中除以0不是一个静态错误而是一个动态错误。
• 无法表示的返回值:例如最后得到的返回值无法用声明的类型来表示。
• 越界访问:例如在一个字符串中使用一个负数索引。
• 使用一个null对象解引用:(null相当于Python中的None)
静态检查倾向于类型错误 ,即与特定的值无关的错误。正如上面提到过的,一个类型是一系列值的集合,而静态类型就是保证变量的值在这个集合中,但是在运行前我们可能不会知道这个值的结果到底是多少。所以如果一个错误必须要特定的值来“触发”(例如除零错误和越界访问),编译器是不会在编译的时候报错的。
与此相对的,动态类型检查倾向于特定值才会触发的错误。
原始类型并不是真正的数
在Java和许多其他语言中存在一个“陷阱”即原始数据类型的对象在有些时候并不像真正的数字那样得到应有的输出。结果就是本来应该被动态检查发现的错误没有报错。例如:
• 整数的除法:5/2并不会返回一个小数,而是一个去掉小数部分的整数对象,因为除法操作符对两个整数对象运算后的结果还是整数,而整数对象是无法表示5/2的精确值的(而我们期望它会是一个动态检查能发现的错误)。
• 整型溢出: int 和 long类型的值的集合是一个有限集合——它们有最大的值和最小的值,当运算的结果过大或者过小的时候我们就很可能得到一个在合法范围内的错误值。
• 浮点类型中的特殊值:在浮点类型例如double中有一些不是数的特殊值:NaN ( “Not a Number”), POSITIVE_INFINITY (正无穷), and NEGATIVE_INFINITY (负无穷).当你对浮点数进行运算的时候可能就会得到这些特殊值(例如除零或者对一个负数开更号),如果你拿着这些特殊值继续做运算,那你可能就会得到一个意想不到结果(译者注:例如拿NaN和别的数进行比较操作永远是False) 。
阅读小练习
让我们尝试一些错误代码的示例,看看它们在Java中的行为。这些错误是静态,动态还是根本不捕获的?
数组和聚集类型
让我们把“冰雹序列”的结果存储在数据结构中而不仅仅是输出。Java中有两种常用的线性存储结构:数组和列表。
数组是一连串类型相同的元素组成的结构,而且它的长度是固定的(元素个数固定)。例如,我们声明一个整型数组变量:
对于数组,常用的操作符有下:
• 索引其中的一个元素: a[2]
• 赋予一个元素特定的值: a[2]=0
• 求数组长度: a.length (和 String.length() 的区别—— a.length 不是一个类内方法调用,所以不能在它后面写上括号和参数)
下面是我们利用数组写的第一个求“冰雹序列”的代码,它存在一些bug:
相信很快你就能发现错误:数组的长度是100(100称为幻数)?如果我们尝试的n的冰雹序列长到大于100的话就无法使用这个数组。假如我们犯了错误,Java是否能够静态地、动态地检查出这个错误或者根本不检查?偶然地,像这样一个固定长度的数组的溢出在像C或者C++这样不太安全的语言中是非常常见的,而且被称为缓冲区溢出。这种溢出是大量网络安全漏洞和网络爬虫的罪魁祸首。
集合类型
解决方法是使用List类型。List类型而不是固定长度的数组。我们可以这样声明列表:
常用的操作符有下:
• 索引一个元素: list.get(2)
• 赋予一个元素特定的值: list.set(2, 0)
• 求列表的长度: list.size()
这里要注意List是一个接口,这种类型的对象无法直接用new来构造,必须用能够实现List要求满足的操作符的方法来构造。我们会在后来讲抽象数据类型的时候具体将价格这个。ArrayList是一个实类型的类,它提供了List操作符的具体实现。ArrayList不是唯一的实现方法,但是是最常用的一个。在Java API的文档里找到很多这方面的信息。另外要注意的是,我们写的是List< Integer >而不是List< int>,不幸的是我们不能直接模仿int[]那样写List< int>。List只知道如何处理对象类型,而不知道原始类型。在Java中,每个原语类型(它们是用小写写的,通常是缩写的,比如int)都有一个等效的对象类型(这个对象类型是大写的,并且拼写完整,就像Integer一样)。Java要求我们在参数化带有尖括号的类型时使用这些对象类型等价物。但是在其他情况下,Java会自动在int和Integer之间进行转换,因此我们可以编写Integer i=5,而不会出现任何类型错误。
下面是用列表编写的“冰雹序列”的实现:
这样实现除了看起来简洁而且更加安全,因为列表会自动扩充它自己以满足新添加的元素(当然,直到你的内存不够用为止)
迭代
对于在一个序列结构(例如列表和数组)遍历元素,Java和Python的写法差不多:
Math.max() 是一个Java API提供的方便的函数。
方法
在Java中,声明通常必须在一个方法中,而每个方法都要在一个类型中,所以写“冰雹序列”程序最简单可以这么写:
public、private、static
• public意味着任何在你程序中的代码都可以访问这个类或者方法。其他的类型修饰符,例如private ,是用来确保程序的安全性的——它保证了可变类型不会被别处的代码所修改。我们会在后面的课程中详细提到。
• static意味这这个方法没有self这个参数——Java会隐含的实现它,所以你不会看到这个参数。静态的方法不能通过对象来调用,例如List add() 方法 或者 String length()方法,它们要求先有一个对象。静态方法的正确调用应该使用类来索引,例如:
另外,记得在定义的方法前面写上注释。这些注释应该描述了这个方法的功能,输入输出/返回,以及注意事项。记住注释不要写的啰嗦,而是应该直切要点,简洁明了。例如在上面的代码中,n是一个整型的变量,这个在声明的时候int已经体现出来了,就不需要进行注释。但是如果我们设想的本意是n不能为负数,而这个编译器(声明)是不能检查和体现出来的,我们就应该注释出来,方便阅读理解和修改。
这些东西我们会在后面的课程中详细介绍,但是你现在就要开始试着正确使用他们。
变化的值vs.可被赋值的改变
我们会介绍“快照图”(snapshot diagrams),以此来辨别修改一个变量和修改一个值的区别。当你给一个变量赋值的时候,你实际上是在改变这个变量指向的对象(值也不一样)。
而当你对一个可变的值进行赋值操作的时候——例如数组或者列表——你实际上是在改变对象本身的内容。
变化是“邪恶”的,好的程序员会避免可改变的东西,因为这些改变可能是意料之外的。
不变性(Immutability)是我们这门课程的一个重要设计原则。不变类型是指那些这种类型的对象一旦创建其内容就不能被更改的类型(至少外部看起来是这样,我们在后面的的课程中会说一些替代方案)。思考一下在上面的代码中哪一些类型是可更改类型,哪一些不是?(例如int就是不变的,List就是可变的,给int类型的对象赋值就会让它指向一个新的对象)
Java也给我们提供了不变的索引:只要变量被初始化后就不能再次被赋值了——只要在声明的时候加上final :
如果编译器发现你的final变量不仅仅是在初始化的时候被“赋值”,那么它就会报错。换句话说,final会提供不变索引的静态检查。
正确的使用final是一个好习惯,就好像类型声明一样,这不仅会让编译器帮助你做静态检查,同时别人读起来也会更顺利一些。
在hailstoneSequence方法中有两个变量n和list,我们可以将它们声明为final吗?请说明理由。(译者注:n不行,list可以。因为我们需要改变n指向的对象,而List对象本身是可以更改的,我们也不需要改变list对应的对象)
记录设想
在文档中写下变量的类型记录了一个关于它的设想, 例如这个变量总是指向一个整型. 在编译的时候 Java 就会检查这个设想, 并且保证在你的代码中没有任何一处违背这个设想。
而使用 final 关键字去定义一个变量也是一种记录设想, 要求这个变量在其被赋值之后就永远不会再被修改, Java 也会对其进行静态地检查。
不幸的是 Java 并不会自动检查所有设想,例如:n 必须为正数。
为什么我们需要写下我们的设想呢? 因为编程就是不断的设想, 如果我们不写下他们, 就可能会遗忘掉他们, 而且如果以后别人想要阅读或者修改我们的软件, 他们就会很难理解代码, 不得不去猜测(译者注: 变量的含义/函数的描述/返回值描述等等)
所以在编程的时候我们必须朝着如下两个目标努力:
• 与计算机交流. 首先说服编译器你的程序语法正确并且类型正确, 然后保证逻辑正确, 这样就可以让它在运行的时候给我们正确的结果。
• 与其他人交流. 尽可能使你的程序易于理解, 所以当有人想要在将来某些时候修正它, 改进它或者对其进行适配的时候, 他们可以很方便地实现自己的想法。
黑客派(Hacking)vs.工程派(Engineering)
黑客派 的编程风格可以理解为“放飞自我并且乐观的”(贬义):
• 缺点: 在已经编写大量代码以后才测试它们
• 缺点: 将所有的细节都放在脑子里, 以为自己可以永远记住所有的代码, 而不是将它们编写在代码中
• 缺点: 认为 BUG 都不存在或者它们都非常容易发现和被修复.
而 工程派 对应的做法是(褒义):
• 优点: 一次只写一点点, 一边写一边测试. 在将来的课程中, 我们将会探讨"测试优先编程" (test-first programming)
• 优点: 记录代码的设想、意图 (document the assumptions that your code depends on)
• 优点: 静态代码检查将会保护你的代码不沦为“愚蠢的代码”
本课程的目标
本门课程的主要目标为学习如何生产具有如下属性的软件:
• 远离bug 正确性 (现在看起来是正确的), 防御性 (将来也是正确的)
• 易读性 我们不得不和以后有可能需要理解和修改代码的程序员进行交流 (修改 BUG 或者添加新的功能), 那个将来的程序员或许会是几个月或者几年以后的你, 如果你不进行交流, 那么到了那个时候, 你将会惊讶于你居然忘记了这么多, 并且这将会极大地帮助未来的你有一个良好的设计。
• 可改动性. 软件总是在更新迭代的, 一些好的设计可以让这个过程变得非常容易, 但是也有一些设计将会需要让开发者扔掉或者重构大量的代码。
软件还有其他重要属性(如性能,可用性,安全性),它们可能会与这三个属性相抵触。但是,这些是我们在本次课程中关注的三大巨头,并且软件开发人员通常将其放在构建软件的实践中。值得考虑的是我们在本课程中学习的每种语言功能,每种编程实践,每种设计模式,并了解它们与三巨头的关系。
总结
我们今天主要介绍的思想为静态代码检查, 下面是该思想和我们课程目标的关系:
• 帮助我们远离bug 静态代码检查可以通过捕捉类型错误等其他BUG帮助我们在运行代码之前就发现它们
• 易读性 它可以帮助我们理解, 因为所有的类型在代码中被明确定义 (译者注: 相比于 Python/PHP 这类动态变量类型的语言)
• 可改动性 静态代码检查可以在你在修改你的代码的时候定位出也需要被修改的地方, 例如: 当你改变一个变量的类型或者名称的时候, 编译器立即就会在所有使用到这个变量的地方显示错误, 提示你也需要更新它们。