<font size="3">javascript
本文内容来自MIT_6.031_sp18: Software Construction课程的Readings部分,采用CC BY-SA 4.0协议。html
因为咱们学校(哈工大)大二软件构造课程的大部分素材取自此,也是推荐的阅读材料之一,因而打算作一些翻译工做,本身学习的同时也能帮到一些懒得看英文的朋友。另外,该课程的阅读资料中有许多练习题,可是没有标准答案,所给出的答案均为译者所写,有错误的地方还请指出。java
<br />python
<br />git
译者:李秋豪程序员
审校:web
V1.0 Fri Mar 23 17:20:24 CST 2018算法
<br />spring
译者注:mutability还能够翻译为“易变性”(that can change; likely to change),“易”彷佛也能突出使用应该谨慎,但后来我仍是以为“可变”更准确,因此就译为“可变性”。shell
<br />
译者注:麻省理工理工是先教的Python,Java并无做为课程。因此这里提供了一组从Python到Java过渡资料。Java基础好的朋友这节能够跳过。
From Ken Lambert’s tutorial From Python to Java, read the first 8 pages under Defining Classes:
- Class Structure
- Visibility Modifiers
- Instance Variables and Constructors
- Defining Other Constructors
- Instance Methods
- Method Overloading
- Class (
static
) Variables and Methods- Symbolic Constants (
final
Variables)Optional: if you want to see more examples, read these Java Tutorials pages:
Classes and objects
class Tortoise: def __init__(self): self.position = 0 def forward(self): self.position += 1 pokey = Tortoise() pokey.forward() print(pokey.position)
若是咱们将 Tortoise
转换为Java,应该怎么进行声明?
public class Tortoise
Under construction
在Python中,咱们经过声明 __init__
函数来初始化新的对象。
在Java中相似的声明应该怎么写?
public Tortoise()
咱们应该怎么索引到一个新的 Tortoise
对象?
Tortoise t = new Tortoise()
Methodical
咱们在一个 Tortoise
对象中声明一个 forward
方法:
public void forward() { // self.position += 1 (Python) }
如下哪一行代码能够达到代码中注释行的目的:
[x] position += 1;
[ ] self.position += 1;
[x] this.position += 1;
[ ] Tortoise.position += 1;
On your mark
在Python中,咱们经过 self.position = 0
初始化 Tortoise
对象中 position
为0.
使用一行代码将 position
初始化:
public class Tortoise { private int position = 0; // (1) static int position = 0; // (2) public Tortoise() { int position = 0; // (3) int self.position = 0; // (4) int this.position = 0; // (5) int Tortoise.position = 0; // (6) } // ... }
[x] 1
[ ] 2
[ ] 3
[ ] 4
[ ] 5
[ ] 6
或者用几行初始化 position
:
public class Tortoise { private int position; // (1) static int position; // (2) public Tortoise() { self.position = 0; // (3) this.position = 0; // (4) Tortoise.position = 0; // (5) } // ... }
[x] 1
[ ] 2
[ ] 3
[x] 4
[ ] 5
Get set
如今咱们再声明另外一个方法 Tortoise
:
public void jump(int position) { // set this Tortoise's position to the input value }
如下哪一行能够将代码中注释部分实现?
[ ] position = position;
[ ] position = this.position;
[x] this.position = position;
[ ] this.position = this.position;
Static vs. instance
假设咱们想到记录和 Tortoise
类及对象有关的信息,下面哪个声明是合理的?
记录有多少个对象已经被建立了:
int numberOfTortoisesInWorld;
static int numberOfTortoisesInWorld;
记录tortoise对象中shell的颜色:
Color shell;
static Color shell;
对象的母亲和父亲:
Tortoise mother, father;
static Tortoise mother, father;
<br />
回忆以前咱们讨论过的“用快照图理解值与对象”(译者注:“Java基础”),有一些对象的内容是不变的(immutable):一旦它们被建立,它们老是表示相同的值。另外一些对象是可变的(mutable):它们有改变内部值对应的方法。
String
就是不变对象的一个例子,一个String
对象老是表示相同的字符串。而StringBuilder
则是可变的,它有对应的方法来删除、插入、替换字符串内部的字符,等等。
由于 String
是不变的,一旦被建立,一个 String
对象老是有同样的值。为了在一个 String
对象字符串后加上另外一个字符串,你必须建立一个新的 String
对象:
String s = "a"; s = s.concat("b"); // s+="b" and s=s+"b" also mean the same thing
与此相对, StringBuilder
对象是可变的。这个类有对应的方法来改变对象,而不是返回一个新的对象:
StringBuilder sb = new StringBuilder("a"); sb.append("b");
因此这有什么关系呢?在上面这两个例子中,咱们最终都让s
和sb
索引到了"ab"
。当对象的索引只有一个时,它们两确实没什么去呗。可是当有别的索引指向同一个对象时,它们的行为会大不相同。例如,当另外一个变量t
指向s
对应的对象,tb
指向sb
对应的对象,这个时候对t
和tb
作更改就会致使不一样的结果:
String t = s; t = t + "c"; StringBuilder tb = sb; tb.append("c");
能够看到,改变t
并无对s
产生影响,可是改变tb
确实影响到了sb
——这可能会让编程者惊讶一下(若是他没有注意的话)。这也是下面咱们会重点讨论的问题。
既然咱们已经有了不变的 String
类,为何还要使用可变的 StringBuilder
类呢?一个常见的使用环境就是当你要同时建立大量的字符串,例如:
String s = ""; for (int i = 0; i < n; ++i) { s = s + i; }
若是使用不变的字符串,这会发生不少“暂时拷贝”——第一个字符“0”实际上就被拷贝了n次,第二个字符被拷贝了n-1次,等等。总的来讲,它会花费O(N^2)的时间来作拷贝,即便最终咱们的字符串只有n个字符。
StringBuilder
的设计就是为了最小化这样的拷贝,它使用了简单可是聪明的内部结构避免了作任何拷贝(除非到了极限状况)。若是你使用StringBuilder
,能够在最后用 toString()
方法获得一个String
的结果:
StringBuilder sb = new StringBuilder(); for (int i = 0; i < n; ++i) { sb.append(String.valueOf(i)); } String s = sb.toString();
优化性能是咱们使用可变对象的缘由之一。另外一个缘由是为了分享:程序中的两个地方的代码能够经过共享一个数据结构进行交流。
Follow me
一个 terrarium
的使用者能够更改红色的 Turtle
对象吗?
[ ] 不能,由于到 terrarium
的索引是不变的
[x] 不能,由于 Turtle
对象是不变的
[ ] 能够,由于从列表的0下标处到 Turtle
的索引是可变的。
[ ] 能够,由于 Turtle
对象是可变的
一个 george
的使用者能够更改蓝色的 Gecko
对象吗?
[ ] 不能,由于到george
的索引是不变的
[x] 不能,由于 Gecko
对象是不变的
[ ] 能够,由于从列表的1下标处到 Gecko
的索引是可变的。
[ ] 能够,由于 Gecko
对象是可变的
一个 petStore
的使用者可使得另外一个 terrarium
的使用者没法访问蓝色的 Gecko
对象吗?选出最好的答案
[ ] 不能,由于到 terrarium
的索引是不变的
[ ] 不能,由于 Gecko
对象是不变的
[ ] 能够,由于到 petStore
的索引是可变的
[ ] 能够,由于 PetStore
对象是可变的
[x] 能够,由于 List
对象是可变的
[ ] 能够,由于从列表的1下标处到 Gecko
的索引是可变的。
<br />
可变的类型看起来比不可变类型强大的多。若是你在“数据类型商场”购物,为何要选择“无聊的”不可变类型而放弃强大的可变类型呢?例如 StringBuilder
应该能够作任何 String
能够作的事情,加上 set()
和 append()
这些功能。
答案是**使用不可变类型要比可变类型安全的多,同时也会让代码更易懂、更具有可改动性。**可变性会使得别人很难知道你的代码在干嘛,也更难制定开发规定(例如规格说明)。这里举出了两个例子:
下面这个方法将列表中的整数相加求和:
/** @return the sum of the numbers in the list */ public static int sum(List<Integer> list) { int sum = 0; for (int x : list) sum += x; return sum; }
假设如今咱们要建立另一个方法,这个方法将列表中数的绝对值相加,根据DRY原则(Don’t Repeat Yourself),实现者写了一个利用 sum()
的方法:
/** @return the sum of the absolute values of the numbers in the list */ public static int sumAbsolute(List<Integer> list) { // let's reuse sum(), because DRY, so first we take absolute values for (int i = 0; i < list.size(); ++i) list.set(i, Math.abs(list.get(i))); return sum(list); }
注意到这个方法直接改变了数组 —— 这对实现者来讲很合理,由于利用一个已经存在的列表会更有效率。若是这个列表有几百万个元素,那么你节省内存的同时也节省了大量时间。因此实现者的理由很充分:DRY与性能。
可是使用者可能会对结果很惊奇,例如:
// meanwhile, somewhere else in the code... public static void main(String[] args) { // ... List<Integer> myData = Arrays.asList(-5, -3, -2); System.out.println(sumAbsolute(myData)); System.out.println(sum(myData)); }
Risky #1
上面的代码会打印出哪两个数?
10
10
让咱们想一想这个问题的关键点:
sumAbsolute()
的实现者,由于他可能违背了规格说明。可是,**传入可变对象真的(可能)会致使隐秘的bug。**只要有一个程序员不当心将这个传入的列表更改了(例如为了复用或性能),程序就可能会出错,并且bug很难追查。main()
的时候,你会对 sum()
和 sumAbsolute()
作出哪些假设?对于读者来讲,他能清晰的知道 myData
会被更改吗?咱们刚刚看到了传入可变对象可能会致使问题。那么返回一个可变对象呢?
Date
是一个Java内置的类, 同时 Date
也正好是一个可变类型。假设咱们写了一个判断春天的第一天的方法:
/** @return the first day of spring this year */ public static Date startOfSpring() { return askGroundhog(); }
这里咱们使用了有名的土拨鼠算法 (Harold Ramis, Bill Murray, et al. Groundhog Day, 1993).
如今使用者用这个方法来计划他们的派对开始时间:
// somewhere else in the code... public static void partyPlanning() { Date partyDate = startOfSpring(); // ... }
这段代码工做的很好。不过过了一段时间,startOfSpring()
的实现者发现“土拨鼠”被问的不耐烦了,因而打算重写startOfSpring()
,使得“土拨鼠”最多被问一次,而后缓存下此次的答案,之后直接从缓存读取:
/** @return the first day of spring this year */ public static Date startOfSpring() { if (groundhogAnswer == null) groundhogAnswer = askGroundhog(); return groundhogAnswer; } private static Date groundhogAnswer = null;
(思考:这里缓存使用了private static
修饰符,你认为它是全局变量吗?)
另外,有一个使用者以为startOfSpring()
返回的日期太冷了,因此他把日期延后了一个月:
// somewhere else in the code... public static void partyPlanning() { // let's have a party one month after spring starts! Date partyDate = startOfSpring(); partyDate.setMonth(partyDate.getMonth() + 1); // ... uh-oh. what just happened? }
(思考:这里还有另一个隐秘的bug——partyDate.getMonth() + 1
,你知道为何吗?)
这两个改动发生后,你以为程序会出现什么问题?更糟糕的是,谁会先发现这个bug呢?是这个 startOfSpring()
,仍是 partyPlanning()
? 或是在另外一个地方使用 startOfSpring()
的无辜者?
Risky #2
咱们不知道Date
具体是怎么存储月份的,因此这里用抽象的值 ...march...
和 ...april...
表示,Date
中有一个mounth
索引到这些值上。
如下哪个快照图表现了上文中的bug?
[ ]
[ ]
[ ]
[x]
[ ]
Understanding risky example #2
partyPlanning
在不知不觉中修改了春天的起始位置,由于 partyDate
和 groundhogAnswer
指向了同一个可变Date
对象 。
更糟糕的是,这个bug可能不会在这里的 partyPlanning()
或 startOfSpring()
中出现。而是在另一个调用 startOfSpring()
的地方出现,获得一个错误的值而后继续进行运算。
上文中的缓存 groundhogAnswer
是全局变量吗?
[ ] 是全局变量,这是合理的
[ ] 是全局变量,这是不合理的
[x] 不是全局变量
A second bug
上文中的代码在加上1月的时候存在另外一个bug,请阅读 Java API documentation for Date.getMonth
和 setMonth
.
对于 partyDate.getMonth()
,它的哪个返回值会致使bug的发生?
11
NoSuchMonthException
上面关于 Date.setMonth
文档中说: month: the month value between 0-11
.那么当这个bug触发的时候可能会发生什么?
[x] 这个方法不会作任何事情
[x] 这个方法会按照咱们本来的想法运行
[x] 这个方法会使得 Date
对象不可用,并报告一个错误的值
[ ] 这个方法会抛出一个已检查异常
[x] 这个方法会抛出一个未检查异常
[x] 这个方法会将时间设置为9/9/99
[x] 这个方法会使得其余的 Date
对象也不可用
[x] 这个方法永远不会返回
SuchTerribleSpecificationsException
在关于 Date
的文档中,有一句话是这样说的,“传入方法的参数并不必定要落在指定的区域内,例如传入1月32号意味着2月1号”。
这看起来像是前置条件...但它不是的!
下面哪个选项表现了Date
这个特性是不合理的?
<br />
关键点:
在上面举出的两个例子( List<Integer>
和 Date
)中,若是咱们采用不可变对象,这些问题就迎刃而解了——这些bug在设计上就不可能发生。
事实上,你绝对不该该使用Date
!而是使用 包 java.time
: LocalDateTime
, Instant
, 等等这些类,它们规格说明都保证了对象是不可变的。
这个例子也说明了使用可变对象可能会致使性能上的损失。由于为了在不修改规格说明和接口的前提下避开这个bug,咱们必须让startOfSpring()
返回一个复制品:
return new Date(groundhogAnswer.getTime());
这样的模式称为防护性复制 ,咱们在后面讲抽象数据类型的时候会讲解更多关于防护性复制的东西。这样的方法意味着 partyPlanning()
能够自由的操控startOfSpring()
的返回值而不影响其中的缓存。可是防护性复制会强制要求 startOfSpring()
为每个使用者复制相同数据——即便99%的内容使用者都不会更改,这会很浪费空间和时间。相反,若是咱们使用不可变类型,不一样的地方用不一样的对象来表示,相同的地方都索引到内存中同一个对象,这样会让程序节省空间和复制的时间。因此说,合理利用不变性对象(译者注:大可能是有多个变量索引的时候)的性能比使用可变性对象的性能更好。
<br />
事实上,若是你只在一个方法内使用可变类型并且该类型的对象只有一个索引,这时并不会有什么风险。而上面的例子告诉咱们,若是一个可变对象有多个变量索引到它——这也被称做“别名”,这时就会有产生bug的风险。
Aliasing 1
如下代码的输出是什么?
List<String> a = new ArrayList<>(); a.add("cat"); List<String> b = a; b.add("dog"); System.out.println(a); System.out.println(b);
[ ] ["cat"]
`["cat", "dog"]`
[x] ["cat", "dog"]
`["cat", "dog"]`
[ ] ["cat"]
`["cat"]`
[ ] ["dog"]
`["dog"]`
如今试着使用快照图将上面的两个例子过一遍,这里只列出一个轮廓:
List
例子中,一个相同的列表被list
(在 sum
和 sumAbsolute
中)和myData
(在main
中)同时索引。一个程序员(sumAbsolute
的)认为更改这个列表是ok的;另外一个程序员(main
)但愿列表保持原样。因为别名的使用,main
的程序员获得了一个错误的结果。Date
的例子中,有两个变量 groundhogAnswer
和 partyDate
索引到同一个Date
对象。这两个别名出如今程序的不一样地方,因此不一样的程序员很难知作别人会对这个Date
对象作哪些改变。先在纸上画出快照图,可是你真正的目标应该是在脑海中构建一个快照图,这样之后你在看代码的时候也能将其“视觉化”。
<br />
从上面的分析来看,咱们必须使用以前提到过的格式对那些会更改参数对象的方法写上特定的规格说明。
下面是一个会更改参数对象的方法:
static void sort(List<String> lst) - requires: nothing - effects: puts lst in sorted order, i.e. lst[i] ≤ lst[j] for all 0 ≤ i < j < lst.size()
而这个是一个不会更改参数对象的方法:
static List<String> toLowerCase(List<String> lst) - requires: nothing - effects: returns a new list t where t[i] = lst[i].toLowerCase()
若是在effects内没有显式强调输入参数会被更改,在本门课程中咱们会认为方法不会修改输入参数。事实上,这也是一个编程界的一个约定俗成的规则。
<br />
接下来咱们会看看另外一个可变对象——迭代器 。迭代器会尝试遍历一个聚合类型的对象,并逐个返回其中的元素。当你在Java中使用for (... : ...)
这样的遍历元素的循环时,其实就隐式的使用了迭代器。例如:
List<String> lst = ...; for (String str : lst) { System.out.println(str); }
会被编译器理解为下面这样:
List<String> lst = ...; Iterator<String> iter = lst.iterator(); while (iter.hasNext()) { String str = iter.next(); System.out.println(str); }
一个迭代器有两种方法:
next()
返回聚合类型对象的下一个元素hasNext()
测试迭代器是否已经遍历到聚合类型对象的结尾注意到next()
是一个会修改迭代器的方法(mutator method),它不只会返回一个元素,并且会改变内部状态,使得下一次使用它的时候会返回下一个元素。
感兴趣的话,你能够读读Java API中关于迭代器的定义 .
MyIterator
为了更好的理解迭代器是如何工做的,这里有一个ArrayList<String>
迭代器的简单实现:
/** * A MyIterator is a mutable object that iterates over * the elements of an ArrayList<String>, from first to last. * This is just an example to show how an iterator works. * In practice, you should use the ArrayList's own iterator * object, returned by its iterator() method. */ public class MyIterator { private final ArrayList<String> list; private int index; // list[index] is the next element that will be returned // by next() // index == list.size() means no more elements to return /** * Make an iterator. * @param list list to iterate over */ public MyIterator(ArrayList<String> list) { this.list = list; this.index = 0; } /** * Test whether the iterator has more elements to return. * @return true if next() will return another element, * false if all elements have been returned */ public boolean hasNext() { return index < list.size(); } /** * Get the next element of the list. * Requires: hasNext() returns true. * Modifies: this iterator to advance it to the element * following the returned element. * @return next element of the list */ public String next() { final String element = list.get(index); ++index; return element; } }
MyIterator
使用到了许多Java的特性,例如构造体,static和final变量等等,你应该确保本身已经理解了这些特性。参考: From Python to Java 或 Classes and Objects in the Java Tutorials
上图画出了 MyIterator
初始状态的快照图。
注意到咱们将list
的索引用双箭头表示,以此表示这是一个不能更改的final索引。可是list索引的 ArrayList
自己是一个可变对象——内部的元素能够被改变——将list
声明为final并不能阻止这种改变。
那么为何要使用迭代器呢?由于不一样的聚合类型其内部实现的数据结构都不相同(例如链接链表、哈希表、映射等等),而迭代器的思想就是提供一个访问元素的通用中间件。经过使用迭代器,使用者只须要用一种通用的格式就能够遍历访问聚合类的元素,而实现者能够自由的更改内部实现方法。大多数现代语言(Python、C#、Ruby)都使用了迭代器。这是一种有效的设计模式 (一种被普遍测试过的解决方案)。咱们在后面的课程中会看到不少其余的设计模式。
MyIterator.next signature
迭代器的实现中使用到了实例方法(instance methods),实例方法是在一个实例化对象上进行操做的,它被调用时会传入一个隐式的参数this
(就像Python中的self
同样),经过这个this
该方法能够访问对象的数据(fields)。
咱们首先看看 MyIterator
中的 next
方法:
public class MyIterator { private final ArrayList<String> list; private int index; ... /** * Get the next element of the list. * Requires: hasNext() returns true. * Modifies: this iterator to advance it to the element * following the returned element. * @return next element of the list */ public String next() { final String element = list.get(index); ++index; return element; } }
next
的输入是什么类型?
[ ] void
– 没有输入
[ ] ArrayList
[x] MyIterator
[ ] String
[ ] boolean
[ ] int
next
的输出是什么类型?
[ ] void
– 没有输出
[ ] ArrayList
[ ] MyIterator
[x] String
[ ] boolean
[ ] int
MyIterator.next precondition
next
有前置条件 requires: hasNext() returns true.
next
的哪个输入被这个前置条件所限制?
[ ] 都没有被限制
[x] this
[ ] hasNext
[ ] element
当前置条件不知足时,实现的代码能够去作任何事。具体到咱们的实现中,若是前置条件不知足,代码会有什么行为?
[ ] 返回 null
[ ] 返回列表中其余的元素
[ ] 抛出一个已检查异常
[x] 抛出一个非检查异常
MyIterator.next postcondition
next
的一个后置条件是 @return next element of the list
.
next
的哪个输出被这个后置条件所限制?
[ ] 都没有被限制
[ ] this
[ ] hasNext
[x] 返回值
next
的另一个后置条件是 modifies: this iterator to advance it to the element following the returned element.
什么会被这个后置条件所限制?
[ ] 都没有被限制
[x] this
[ ] hasNext
[ ] 返回值
<br />
如今让咱们试着将迭代器用于一个简单的任务。假设咱们有一个MIT的课程代号列表,例如["6.031", "8.03", "9.00"]
,咱们想要设计一个 dropCourse6
方法,它会将列表中全部以“6.”开头的代号删除。根据以前所说的,咱们先写出以下规格说明:
/** * Drop all subjects that are from Course 6. * Modifies subjects list by removing subjects that start with "6." * * @param subjects list of MIT subject numbers */ public static void dropCourse6(ArrayList<String> subjects)
注意到 dropCourse6
显式的强调了它会对参数 subjects
作修改。
接下来,根据测试优先编程的原则,咱们对输入空间进行分区,并写出了如下测试用例:
// Testing strategy: // subjects.size: 0, 1, n // contents: no 6.xx, one 6.xx, all 6.xx // position: 6.xx at start, 6.xx in middle, 6.xx at end // Test cases: // [] => [] // ["8.03"] => ["8.03"] // ["14.03", "9.00", "21L.005"] => ["14.03", "9.00", "21L.005"] // ["2.001", "6.01", "18.03"] => ["2.001", "18.03"] // ["6.045", "6.031", "6.813"] => []
最后,咱们实现dropCourse6
方法:
public static void dropCourse6(ArrayList<String> subjects) { MyIterator iter = new MyIterator(subjects); while (iter.hasNext()) { String subject = iter.next(); if (subject.startsWith("6.")) { subjects.remove(subject); } } }
可是当咱们测试的时候,最后一个例子报错了:
// dropCourse6(["6.045", "6.031", "6.813"]) // expected [], actual ["6.031"]
dropCourse6
彷佛没有将列表中的元素清空,为何?为了追查bug是在哪发生的,咱们建议你画出一个快照图,并逐步模拟程序的运行。
Draw a snapshot diagram
如今画出一个初始(代码未执行)快照图。你须要参考上面MyIterator
类和 dropCourse6()
方法的代码实现。
在你的初始快照图中有哪些标签?
[ ] iter
[ ] index
[x] list
[x] subjects
[ ] subject
[x] ArrayList
[ ] List
[ ] MyIterator
[x] String
[ ] dropCourse6
如今执行第一条语句 MyIterator iter = new MyIterator(subjects);
,你的快照图中又有哪些标签?
[x] iter
[x] index
[x] list
[x] subjects
[ ] subject
[x] ArrayList
[ ] List
[x] MyIterator
[x] String
[ ] dropCourse6
Entering the loop
如今执行接下来的语句String subject = iter.next()
.,你的快照图中添加了什么东西?
[ ] 一个从 subject
到ArrayList 0
下标的箭头
[ ] 一个从 subject
到ArrayList 1
下标的箭头
[ ] 一个从index
到 0
的箭头
[x] 一个从index
到 1
的箭头
这个时候subject.startsWith("6.")
返回是什么?
[x] 真,由于 subject
索引到了字符串 "6.045"
[ ] 真,由于 subject
索引到了字符串 "6.031"
[ ] 真,由于 subject
索引到了字符串 "6.813"
[ ] 假,由于 subject
索引到了其余字符串
Remove an item
如今画出在 subjects.remove(subject)
语句执行后的快照图。
如今ArrayList subjects
是什么样子?
[ ] 下标0对应 "6.045"
[x] 下标0对应 "6.031"
[ ] 下标0对应 "6.813"
[ ] 没有下标0
[ ] 下标1对应 "6.045"
[ ] 下标1对应 "6.031"
[x] 下标1对应 "6.813"
[ ] 没有下标1
[ ] 下标2对应 "6.045"
[ ] 下标2对应 "6.031"
[ ] 下标2对应 "6.813"
[x] 没有下标2
Next iteration of the loop
如今进行下一次循环,执行语句 iter.hasNext()
和String subject = iter.next()
,此时 subject.startsWith("6.")
的返回是什么?
subject
索引到了字符串 "6.045"
subject
索引到了字符串 "6.031"
subject
索引到了字符串 "6.813"
subject
索引到了其余字符串在这个测试用例中,哪个ArrayList中的元素永远不会被 MyIterator.next()
返回?
[ ] "6.045"
[x] "6.031"
[ ] "6.813"
若是你想要解释这个bug是如何发生的,如下哪一些声明会出如今你的报告里?
[x] list
和 subjects
是一对别名,它们都指向同一个 ArrayList
对象.
[x] 一个列表在程序的两个地方被使用别名,当一个别名修改列表时,另外一个别名处不会被告知。
[ ] 代码没有检查列表中奇数下标的元素。
[x] MyIterator
在迭代的时候是假设迭代对象不会发生更改的。
其实,这并非咱们设计的 MyIterator
带来的bug。Java内置的 ArrayList
迭代器也会有这样的问题,在使用for
遍历循环这样的语法糖是也会出现bug,只是表现形式不同,例如:
for (String subject : subjects) { if (subject.startsWith("6.")) { subjects.remove(subject); } }
这段代码会抛出一个 ConcurrentModificationException
异常,由于这个迭代器检测到了你在对迭代对象进行修改(你以为它是怎么检测到的?)。
那么应该怎修改这个问题呢?一个方法就是使用迭代器的 remove()
方法(而不是直接操做迭代对象),这样迭代器就能自动调整迭代索引了:
Iterator iter = subjects.iterator(); while (iter.hasNext()) { String subject = iter.next(); if (subject.startsWith("6.")) { iter.remove(); } }
事实上,这样作也会更有效率,由于 iter.remove()
知道要删除的元素的位置,而 subjects.remove()
对整个聚合类进行一次搜索定位。
可是这并无彻底解决问题,**若是有另外一个迭代器并行对同一个列表进行迭代呢?**它们之间不会互相告知修改!
Pick a snapshot diagram
如下哪个快照图描述了上面所述并行bug的发生?
[ ]
[ ]
[x]
[ ]
[ ]
<br />
这也是使用可变数据结构的一个基本问题。一个可变对象有多个索引(对于对象来讲称做“别名”)意味着在你程序的不一样位置(可能分布很广)都依赖着这个对象保持不变。
为了将这种限制放到规格说明中,规格不能只在一个地方出现,例如在使用者的类和实现者的类中都要有。如今程序正常运行依赖着每个索引可变对象的人遵照相应制约。
做为这种非本地制约“契约”,想一想Java中的聚合类型,它们的文档都清楚的写出来使用者和实现者应该遵照的制约。试着找到它对使用者的制约——你不能在迭代一个聚合类时修改其自己。另外,这是哪一层类的责任?Iterator
? List
? Collection
? 你能找出来吗?
同时,这样的全局特性也会使得代码更难读懂,而且正确性也更难保证。但咱们不得不使用它——为了性能或者方便——可是咱们也会为安全性付出巨大的代价。
可变对象还会使得使用者和实现者之间的契约更加复杂,这减小了实现者和使用者改变代码的自由度。这里举出了一个例子。
下面这个方法在MIT的数据库中查找并返回用户的9位数ID:
/** * @param username username of person to look up * @return the 9-digit MIT identifier for username. * @throws NoSuchUserException if nobody with username is in MIT's database */ public static char[] getMitId(String username) throws NoSuchUserException { // ... look up username in MIT's database and return the 9-digit ID }
假设有一个使用者:
char[] id = getMitId("bitdiddle"); System.out.println(id);
如今使用者和实现者都打算作一些改变: 使用者以为要照顾用户的隐私,因此他只输出后四位ID:
char[] id = getMitId("bitdiddle"); for (int i = 0; i < 5; ++i) { id[i] = '*'; } System.out.println(id);
而实现者担忧查找的性能,因此它引入了一个缓存记录已经被查找过的用户:
private static Map<String, char[]> cache = new HashMap<String, char[]>(); public static char[] getMitId(String username) throws NoSuchUserException { // see if it's in the cache already if (cache.containsKey(username)) { return cache.get(username); } // ... look up username in MIT's database ... // store it in the cache for future lookups cache.put(username, id); return id; }
这两个改变致使了一个隐秘的bug。如上图所示,当使用者查找 "bitdiddle"
并获得一个字符数组后,实现者也缓存的是这个数组,他们两个实际上索引的是同一个数组(别名)。这意味着用户用来保护隐私的代码会修改掉实现者的缓存,因此将来调用 getMitId("bitdiddle")
并不会返回一个九位数,例如 “928432033” ,而是修改后的 “*****2033”。
**共享可变对象会增长契约的复杂度,**想一想,若是这个错误被交到了“软件工程法庭”审判,哪个人会为此承担责任呢?是修改返回值的使用者?仍是没有保存好返回值的实现者?
下面是一种写规格说明的方法:
public static char[] getMitId(String username) throws NoSuchUserException - requires: nothing - effects: returns an array containing the 9-digit MIT identifier of username, or throws NoSuchUserException if nobody with username is in MIT’s database. Caller may never modify the returned array.
这是一个下下策这样的制约要求使用者在程序中的全部位置都遵循不修改返回值的规定!而且这是很难保证的。
下面是另外一种写规格说明的方法:
public static char[] getMitId(String username) throws NoSuchUserException - requires: nothing - effects: returns a new array containing the 9-digit MIT identifier of username, or throws NoSuchUserException if nobody with username is in MIT’s database.
这也没有彻底解决问题. 虽然这个规格说明强调了返回的是一个新的数组,可是谁又知道实现者在缓存中不是也索引的这个新数组呢?若是是这样,那么用户对这个新数组作的更改也会影响到将来的使用。This spec at least says that the array has to be fresh. But does it keep the implementer from holding an alias to that new array? Does it keep the implementer from changing that array or reusing it in the future for something else?
下面是一个好的多的规格说明:
public static String getMitId(String username) throws NoSuchUserException - requires: nothing - effects: returns the 9-digit MIT identifier of username, or throws NoSuchUserException if nobody with username is in MIT’s database.
经过使用不可变类型String,咱们能够保证使用者和实现者的代码不会互相影响。同时这也不依赖用户认真阅读遵照规格说明。不只如此,这样的方法也给了实现者引入缓存的自由。
给出如下代码:
public class Zoo { private List<String> animals; public Zoo(List<String> animals) { this.animals = animals; } public List<String> getAnimals() { return this.animals; } }
Aliasing 2
下面的输出会是什么?
List<String> a = new ArrayList<>(); a.addAll(Arrays.asList("lion", "tiger", "bear")); Zoo zoo = new Zoo(a); a.add("zebra"); System.out.println(a); System.out.println(zoo.getAnimals());
[x] ["lion", "tiger", "bear", "zebra"]
`["lion", "tiger", "bear", "zebra"]`
[ ] ["lion", "tiger", "bear", "zebra"]
`["zebra", "lion", "tiger", "bear", "zebra"]`
[ ] ["lion", "tiger", "bear"]
`["lion", "tiger", "bear", "zebra"]`
[ ] ["lion", "tiger", "bear", "zebra"]
`["lion", "tiger", "bear"]`
Aliasing 3
接着上面的问题,下面的输出会是什么?
List<String> b = zoo.getAnimals(); b.add("flamingo"); System.out.println(a);
[ ] ["lion", "tiger", "bear"]
[ ] ["lion", "tiger", "bear", "zebra"]
[x] ["lion", "tiger", "bear", "zebra", "flamingo"]
[ ] ["lion", "tiger", "bear", "flamingo"]
<br />
既然不可变类型避开了许多危险,咱们就列出几个Java API中经常使用的不可变类型:
全部的原始类型及其包装都是不可变的。例如使用BigInteger
和 BigDecimal
进行大整数运算。
不要使用可变类型 Date
,而是使用 java.time
中的不可变类型。
Java中常见的聚合类 — List
, Set
, Map
— 都是可变的:ArrayList
, HashMap
等等。可是 Collections
类中提供了能够得到不可修改版本(unmodifiable views)的方法:
你能够将这些不可修改版本当作是对list/set/map作了一下包装。若是一个使用者索引的是包装以后的对象,那么 add
, remove
, put
这些修改就会触发 UnsupportedOperationException
异常。
当咱们要向程序另外一部分传入可变对象前,能够先用上述方法将其包装。要注意的是,这仅仅是一层包装,若是你不当心让别人或本身使用了底层可变对象的索引,这些看起来不可变对象仍是会发生变化!
Collections
也提供了获取不可变空聚合类型对象的方法,例如Collections.emptyList
给出如下代码:
List<String> arraylist = new ArrayList<>(); arraylist.add("hello"); List<String> unmodlist = Collections.unmodifiableList(arraylist); // unmodlist should now always be [ "hello" ]
Unmodifiable
会出现什么类型的错误?
unmodlist.add("goodbye"); System.out.println(unmodlist);
动态错误
Unmodifiable?
输出是什么?
arraylist.add("goodbye"); System.out.println(unmodlist);
[ “hello” “goodbye” ]
Immutability
如下哪些选项是正确的?
[ ] 若是一个类的全部索引都被final修饰,它就是不可变的
[x] 若是一个类的全部实例化数据都不会改变,它就是不可变的
[x] 不可变类型的数据能够被安全的共享
[ ] 经过使用防护性复制,咱们可让对象变成不可变的
[ ] 不可变性使得咱们能够关注于全局而非局部代码
<br />
在这篇阅读中,咱们看到了利用可变性带来的性能优点和方便,可是它也会产生不少风险,使得代码必须考虑全局的行为,极大的增长了规格说明设计的复杂性和代码编写、测试的难度。
确保你已经理解了不可变对象(例如String
)和不可变索引(例如 final
变量)的区别。画快照图可以帮助你理解这些概念:其中对象用圆圈表示,若是是不可变对象,圆圈有两层;索引用一个箭头表示,若是索引是不可变的,用双箭头表示。
本文最重要的一个设计原则就是不变性 :尽可能使用不可变类型和不可变索引。接下来咱们仍是将本文的知识点和咱们的三个目标联系起来:
</font>