3.1 数据类型与类型检验
一. Java
数据类型
Java
数据类型是一些值的集合,以及这些值对应的操作。
以如下 5
种常用的原始类型为例 :
int
long
double
char
boolean
对象类型有:
String
BigInteger
表示任意大小的整数
从 Java
的传统来说,原始类型用小写字母,对象类型的起始字母用大写。
有一些操作符可以对不同类型的对象进行操作,这时我们就称之为可重载 ( overloaded
),例如 Java
中的算术运算符 +
, -
, *
, /
都是可重载的。一些函数也是可重载的。大多数编程语言都有不容程度的重载性。
总之分类为:基本数据类型、面向对象的数据类型。
Primitives | Object Reference Types |
---|---|
int ,long ,byte ,short ,char ,float ,double ,boolean | Classes ,interfaces ,arrays ,enums ,annotations |
只有值没有 ID(与其他值无法区分) | 既有ID,也有值 |
不可变的 | 一些可变一些不可变 |
在栈中分配内存 | 在堆中分配内存 |
Can’t achieve unity of expression | Unity of expression with generics |
代价低 | 代价昂贵 |
对象类型形成层次结构——继承关系:
1.1 静态、动态类型语言及检测
1.1.1 静态、动态类型语言
Java
是一种静态类型的语言。所有变量的类型在编译的时候就已经知道了(程序还没有运行),所以编译器也可以推测出每一个表达式的类型。
- 例如,如果
a
和b
是int
类型的,那么编译器就可以知道a + b
的结果也是int
类型的。事实上,Eclipse
在你写代码的时候就在做这些检查,所以你就能够在编辑的同时发现这些问题。
在动态类型语言中(例如 Python
),这种类型检查是发生在程序运行的时候。
静态类型是静态检查的一种——检查发生在编译的时候。
1.1.2 静态、动态类型检测,无检查
编程语言通常能提供以下三种自动检查的方法:
- 静态检查:
bug
在程序运行前发现,一般是编译阶段。实质上是判断赋的值是否在相应集合内。
如int
在整数集内。其避免了将错误代入到运行阶段,可提高程序正确性 / 健壮性。 - 动态检查:
bug
在程序运行中发现 ,一般是运行阶段。检测具体某一个值是否出错。 - 无检查: 编程语言本身不帮助你发现错误,你必须通过特定的条件(例如输出的结果)检查代码的正确性。
静态类型检测有:
- 语法错误 需要注意的是,即使在动态类型的语言例如
Python
中也会做这种检查:如果你有一个多余的缩进,在运行之前就能发现它。 - 类名 / 函数名错误
- 参数数目错误
- 参数类型错误
- 返回值类型错误
动态类型检测有:
- 非法的参数值 例如除数为
0
- 非法的返回值
- 越界
- 空指针
静态检查倾向于类型错误,即与特定的值无关的错误。正如上面提到过的,一个类型是一系列值的集合,而静态类型就是保证变量的值在这个集合中,但是在运行前我们可能不会知道这个值的结果到底是多少。所以如果一个错误必须要特定的值来“触发”(例如除零错误和越界访问),编译器是不会在编译的时候报错的。
与此相对的,动态类型检查倾向于特定值才会触发的错误。
1.1.3 无检查——特定的错误类型
一些错误是不会被检查出来的。换言之,原始数据类型的对象在有些时候并不像真正的数字那样得到应有的输出。例如:5/2
并不能得到 2.5
;整数加减时可能溢出从而得到意想不到的值;浮点数运算得到 NaN
。
1.1.4 示例
不要因为一些语言的习惯而搞错了其在 Java
中的正确性,看下面的代码:
int n = 5;
if (n) {
n = n + 1;
}
这是静态错误,错误出现在第二行, n 是 int
类型,不是 boolean
类型。
double sum = 7;
double n = 0;
double average = sum / n;
不会报错,但是得到了错误的结果。7/0
结果是 infitity
。
二. 可变性与不可变性
改变一个变量:将该变量指向另一个值的存储空间。
改变一个变量的值:将该变量当前指向的值的存储空间中写入一个新的值。
1. Immutable
不变性
一旦被创建,其值不能被改变。要改变只能新建存储空间后改变指向。
使用不可变类型要比可变类型安全的多,同时也会让代码更易懂、更具备可改动性。可变性会使得别人很难知道你的代码在干吗,也更难制定开发规定(例如规格说明)。
如果编译器无法确定 final
变量不会改变,就提示错误,这也是静态类型检查的一部分。
所以,尽量使用 final
变量作为方法的输入参数、作为局部变量。
final
:
final
类无法派生子类。final
变量无法改变值引用。final
方法无法被子类重写。
- 不变对象:一旦被创建,始终指向同一个值引用
- 可变对象:拥有方法可以修改自己的值引用。
String
,BigInteger
和 BigDecimal
等原始类型和包装类型就是不可变类型的对象。
2. Mutable
可变性
优化性能是我们使用可变对象的原因之一。另一个原因是为了分享:程序中的多个对象可以通过共享一个数据结构共享信息的改变。
StringBuilder
, List
、 Set
和 Map
等常见的聚合类、ArrayList
, HashMap
都是可变的, Date
就是可变类型的变量。
注: Collections
类中提供了可以获得不可变的方法。
这样的类,会使如 add
,remove
,put
这样的修改触发异常。
但这实质上这仅仅是一层包装,如果不小心让别人别人或自己使用了底层可变对象的索引,这些看起来不可变对象还是会发生变化。
当只有一个引用指向该值,没有区别。
有多个引用时,差异就出现了。这种情况称为“别名”。
防御式拷贝模式可以防止可变类型带来的隐藏 bug
,但这会很浪费空间和时间。
相反,如果使用不可变类型,不同的地方用不同的对象来表示,相同的地方都索引到内存中同一个对象,这样会让程序节省空间和复制的时间。所以说,合理利用不变性对象(大多是有多个变量索引的时候)的性能比使用可变性对象的性能更好。
3. 对比
Immutable
不包含任何改变属性的方法、不能有 public
属性、不能以任何方法修改(返回)属性的值。最主要的特点是方便、安全。
但使用不可变类型,对其频繁修改会产生大量的临时拷贝,需要垃圾回收。而可变类型最少化拷贝以提高效率。
String s = "";
for (int i = 0; i < r; ++i) {
s = s + n;
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; ++i) {
sb.append(String.valueOf(i));
}
String s = sb.toString();
总之,使用可变数据类型,也可获得更好的性能,也适合于在多个模块之间共享数据(像全局变量)。
如果在 effects
内没有显式强调输入参数会被更改,我们认为方法不会修改输入参数。
不可变类型更“安全”,在其他质量指标上表现更好。
样例:
String s = " Hello ";
s += " World ";
s.trim( );
System.out.println(s);
需要注意的是:
See the specification of String.trim()
Returns:
A copy of this string with leading and trailing white space removed, or this string if it has no leading or trailing white space.//返回去除前后空格的拷贝而不是改变原有对象
故返回的是
" Hello World "//打印的结果
3.
函数调用时,如果调用了 List
等可变型的数据类型,可能会改变其值。函数可能超出了 spec
范畴,改变了输入参数的值,而这种错误非常难于跟踪和发现。
如:
/** @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;
}
/** return the sum of the absolute values of the number 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);
}
// 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));
}
而实际上输出的是:
10
10
与预期的
10
-10
不相同,这是因为过程中 List
的值发生改变。
/** @return the first day of spring this year */
public static Date startOfSpring() {
return askGroundhog();
}
/** @return the first day of spring this year */
public static Date startOfSpring() {
if (groundhogAnswer == null) groundhogAnswer = askGroundhog();
return groundhogAnswer;
}
private static Date groundhogAnswer = null;
//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?
}
Date
可变变量,由于 partyDate
和 groundhogAnswer
指向同一空间,前者的改变使得后者也改变。
4. 修改方式
防御式拷贝:给客户端返回一个全新的对象。但大部分时候该拷贝不会被客户端修改,可能造成大量的内存浪费。
如果使用不可变类型,则节省了频繁复制的代价。
安全的使用可变类型:局部变量,不会涉及共享;只有一个引用。
如果有多个引用(别名),使用可变类型就非常不安全。
三. 代码快照图(code-level
、run-time
、moment
)
代码快照图用于描述程序运行时的内部状态。
1. 原始值与对象值
原始值(基本类型的值):
原始值都是以常量来表达的。箭头的来源可以是一个变量或者一个对象的内部区域( field
)。
对象值(对象类型的值):
一个对象用一个圆表示。对象内部会有很多区域( field
),这些区域又指向它们对应的值。同时这些区域也是有它们的类型的,例如 int x
。
对象的属性与对象一同放入堆中。
2. 可变对象与不可变对象
不可更改的对象(设计者希望它们一直是这个值)在快照图中以双圆圈的边框表示,例如字符串对象:
String s = "a";
s = s + "b";
与此相对应的, StringBuilder
(Java的一个内置类) 是一个可更改的字符串对象,它内置了许多改变其内容的方法:
StringBuilder sb = new StringBuilder("a");
sb.append("b");
可变对象:
Java
也提供了不可更改的引用:
final
声明,变量一旦被赋值就不能再次改变它的引用(指向的值或者对象,即限定了指向)。
如果 Java
编译器发现 final
声明的变量在运行中被赋值多次,它就会报错。所以 final
就是为不可更改的引用提供了静态检查。
在快照图中,不可更改的引用(final
)用双箭头表示。
这里要特别注意一点, final
只是限定了引用不可变,我们可以将其引用到一个可更改的值 (例如 final StringBuilder sb
),虽然引用不变,但引用的对象本身的内容(指向的值)可以改变。
可变的引用,也可指向不可变的值。
final StringBuilder sb = new StringBuilder("abc");
sb.append ("d");
sb = new StringBuilder("e");
System.out.println(sb);
此时编译阶段出错,但会有输出,输出为
abcd
四. Java 数组及聚合类型、类与方法、API 文档
1. 数组
数组是一连串类型相同的元素组成的结构,而且它的长度是固定的(元素个数固定)。
其中我们用到了 a.length
诸如此类的操作,不加括号。这是由于他不是一个类内的方法调用,不能在其后加上括号和参数。
2. 列表 List
List
类型是一个长度可变的序列结构,并且是抽象接口(定义类型的工作,但是不提供具体的实现代码)。列表可以包含零个或多个对象,而且对象可以出现多次。我们可以在列表中删除或添加元素。我们可以这样声明列表:
List<Integer> list = new ArrayList<Integer>();
常用的操作符有下:
- 索引一个元素:
list.get(2)
- 赋予一个元素特定的值:
list.set(2, 0)
- 求列表的长度:
list.size()
- 在列表的末尾添加元素:
list.add(e)
- 测试列表是否为空:
list.isEmpty()
List
是一个接口,无法进行实例化。List
只能存对象,不能存储基本类型,否则将在编译阶段出错。
由于 List
是一个接口,这种类型的对象无法直接用 new
来构造,但是它指定了 List
必须提供的操作。 ArrayList
是一个实类型的类( concrete type
),它提供了 List
操作符的具体实现。当然, ArrayList
不是唯一的实现方法(还有 LinkedList
等),但是是最常用的一个。
另外要注意的是,我们要写 List<Integer>
而不是 List<int>
。因为 List
只会处理对象类型而不是原始类型。在 Java
中,每一个原始类型都有其对应的对象类型(原始类型使用小写字母名字,例如 int
,而对象类型的开头字母大写,例如 Integer
)。当我们使用尖括号参量化一个类型时, Java
要求我们使用对象类型而非原始类型。在其他的一些情况中, Java
会自动在原始类型和对等的对象类型之间相转换。
3. 映射 Map
Map
是一个二元组,且为抽象接口。在 Python
中,字典的 keys
必须是可哈希的, Java
也是类似。常见的操作有:
- 添加映射
key → val
如map.put(key, val)
- 获取
key
映射的值 如map.get(key)
- 测试
key
是否存在 如map.containsKey(key)
- 删除
key
所在的映射 如map.remove(key)
在快照图中,我们将 Map
表示为包含 key/value
序对的对象。例如一个 Map<String, Turtle>
:
4. 集合 Set
集合是一种含有零个或多个不重复对象的聚合类型,并且为抽象接口。和映射中的 key
相同, Python
中的集合的元素也要求是可哈希的, Java
也是类似。常见操作有:
- 测试集合中是否含有
e
如s1.contains(e)
- 测试是否
s1 ⊇ s2
如s1.containsAll(s2)
- 在
s1
中去除s2
的元素 如s1.removeAll(s2)
在快照图中,我们不用数字索引表示集合的元素(即元素没有顺序的概念),例如一个含有整数的集合:
5. 列表 List
、映射 Map
、集合 Set
的共性
特别地,我们对于初始化,有如下规则:
Python
提供了创建列表和字典的方便方法:
lst = [ "a", "b", "c" ]
dict = { "apple": 5, "banana": 7 }
Java
不是这样 它只为数组提供了类似的创建方法:
String[] arr = { "a", "b", "c" };
我们可以用 Arrays.asList
从数组创建列表:
Arrays.asList(new String[] { "a", "b", "c" })
或者直接提供元素:
Arrays.asList("a", "b", "c")
要注意的是,如果一个 List
是用 Arrays.asList
创建的,它的长度就固定了。
在 Python
中,聚合类中的元素的类型可以不同,但是在 Java
中,我们能够要求编译器对操作进行静态检查,确保聚合类中的元素类型相同。
由于 Java
要求元素的普遍性,我们不能直接使用原始类型作为元素的类型,例如Set<int>
,但是,正如前面所提到的, int
有一个对应的 Integer
”包装“对象类型,我们可以用
Set<Integer> numbers.
为了使用方便, Java
会自动在原始类型和包装过的对象类型中做一些转换,所以如果我们声明一个 List<Integer> sequence
,下面的这个代码也能够正常运行:
sequence.add(5); // add 5 to the sequence
int second = sequence.get(1); // get the second element
List
, Set
, 和 Map
都是接口 :他们定义了类型的工作,但是他们不提供具体的实现代码。这有很多优点,其中一个就是我们能根据具体的环境使用更适合的实现方式。
例如 List
的创建:
List<String> firstNames = new ArrayList<String>();
List<String> lastNames = new LinkedList<String>();
如果左右两边的类型参数都是一样的,Java
可以自动识别,这样可以少打一些字:
List<String> firstNames = new ArrayList<>();
List<String> lastNames = new LinkedList<>();
对于 List
,当你不确定时,使用 ArrayList
。
对于 Set
,默认使用 HashSet
,Java
还提供了 sorted sets
,它使用 Treeset
实现。
对于 Map
,默认使用 HashMap
6. 迭代器 Iterator
一定要注意在循环的时候不要改变你的循环参量(它是可改变的值)。
Iteration
是可变的迭代器。迭代器使用有两种方法:
next()
返回下一个元素,会修改迭代器的方法(mutator method);它不仅会返回一个元素,而且会改变内部状态,使得下一次使用它的时候会返回下一个元素。hasNext()
测试是否到达末尾
List<String> lst = ...;
Iterator iter = lst.iterator();
while (iter.hasNext()) {
String str = iter.next();
System.out.println(str);
}
Java
也提供了一种使用数字索引进行迭代的方法。除非你真的需要数字索引,否则我们不推荐这种写法,它可能会引来一些难以发现的 bug
。
使用迭代器是因为不同的聚合类型其内部实现的数据结构不都相同(例如连接链表、哈希表、映射等等),而迭代器的思想就是提供一个访问元素的通用中间件。通过使用迭代器,使用者只需要用一种通用的格式就可以遍历访问聚合类的元素,而实现者可以自由的更改内部实现方法。
7. 枚举类型 enum
当不可变的值的集合满足“小”和“有限”这两个条件时,将这个集合中的所有值统一定义为一个命名常量就是有意义的。
例如,我们这样初始化与命名:
public enum Month {
JANUARY, FEBRUARY, MARCH, APRIL,
MAY, JUNE, JULY, AUGUST,
SEPTEMBER, OCTOBER, NOVEMBER, DECEMBER;
}
public enum PenColor {
BLACK, GRAY, RED, PINK, ORANGE,
YELLOW, GREEN, CYAN, BLUE, MAGENTA;
}
PenColor drawingColor;
像引用一个被命名的静态常量一样来引用枚举类型的值:
drawingColor = PenColor.RED;
需要强调的是,枚举类型是一个独特的新类型。较老的语言,像 Python2
和 Java
的早期版本,它们倾向于使用数字常量或者字符串来表示这样的值的有限集。但是一个枚举型变量更加“类型安全”,因为它可以发现一些类型错误,如类型不匹配:
int month = TUESDAY; // 如果month定义为整型值(TUESDAY也是一个整型值),那么这样写不会报错(但是从语义上看是错的,因为显然不能将“周四”赋值给一个“月份”,这可能不符合作者的本意)
Month month = DayOfWeek.TUESDAY; // 如果month被定义为枚举类型Month,那么这条语句将会触发静态错误 (static error)
或者拼写错误:
String color = "REd"; // 不报错,拼写错误被忽略
PenColor drawingColor = PenColor.REd; // 当枚举类型的值被拼写错时,会触发静态错误
8. 类、方法
public
意味着任何在你程序中的代码都可以访问这个类或者方法。其他的类型修饰符,例如 private
,是用来确保程序的安全性的——它保证了可变类型不会被别处的代码所修改。
在 Python
中,类的方法与普通的函数有一个特别的区别——它们必须有一个额外的第一个参数名称,但是在调用这个方法的时候你不为这个参数赋值, Python
会提供这个值。这个特别的变量指对象本身,按照惯例它的名称是 self
。
虽然你可以给这个参数任何名称,但是强烈建议你使用 self
这个名称——其他名称都是不赞成你使用的。
使用一个标准的名称有很多优点——你的程序读者可以迅速识别它,如果使用 self
的话,还有些 IDE
(集成开发环境)也可以帮助你。
static
意味着这个方法没有 self
这个参数—— Java 会隐含的实现它,所以你不会看到这个参数。静态的方法不能通过对象来调用,这与 List
add()
方法 或者 String
length()
方法形成对比,它们要求先有一个对象。静态方法的正确调用应该使用类来索引,例如对以下程序:`
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;
}
}
静态方法的调用如下:
Hailstone.hailstoneSequence(100)
9. Java API
文档
API
是应用编程接口( application programming interface
)的简称。比如 Facebook
开放了一个供你编程的 API
(实际上不止一个,因为需要对不同的语言和架构开放不同的 API
),那么你就可以用它来写一个和 Facebook
交互的应用。
java.lang.String
是String
类型的全称。我们仅仅使用"双引号"
这样的方式就可以创建一个String
类型的对象。java.lang.Integer
和其他原始包装器类。在多数情况下,Java
都会自动地在原始类型(如int
)和它们被包装(wrapped
,或者称为“封装,boxed
”)之后的类型之间相互转换。java.util.List
类似Python
中的列表,但是在Python
中,列表是语言的一部分。在Java
中,List
需要用Java
来具体实现。java.util.Map
类似Python
的字典。java.io.File
用于表示硬盘上的文件。让我们看看File
对象提供的方法:我们可以测试这个文件是否可读、删除这个文件、查看这个文件最近一次被修改是什么时候。java.io.FileReader
使我们能够读取文本文件。java.io.BufferedReader
让我们高效地读取文本文件。它还提供一个很有用的特性:一次读取一整行。
更深入地看看 BufferedReader
的文档:
在这一页的顶部是 BufferedReader
的继承关系和一系列已经实现的接口。一个 BufferedReader
对象可以调用这些被列出的类型中定义的所有可用的方法(加上它自己定义的方法)。
接下来会看到它的直接子类,对于一个接口来说就是一个实现类。这可以帮助我们获取诸如 HashMap
是 Map
的直接子类这样的信息。
再往下是对这个类的描述。有时候这些描述会有一些模棱两可,但是如果你要了解一个类,这里就是你的第一选择。
如果你想创建一个 BufferedReader
,那么 constructor summary
版块就是你要看的资料。构造函数并不是 Java
中唯一获取一个新对象的方法,但它却是最为普遍使用的:
接下来是 BufferedReader
对象中所有我们可以调用的方法的列表:
在综述下面是每个方法和构造函数的详细描述。点击一个构造函数或者方法即可看到详细的描述。如果你想弄明白一个方法有什么作用,那你应该查看这里。
每一个详细描述包括以下内容:
- 方法签名(
signature
):我们能看到方法的返回值类型,方法名,以及参数。我们还可以看到一些异常,就目前而言,它们就是运行这个方法可能导致的错误。 - 完整的描述。
- 参数:描述这个方法接收的参数。
- 对方法返回值的描述。
规格说明 :
这些详细的描述称为规格说明。它们使得我们能够在不了解具体实现代码的情况下使用诸如 String
, Map
, 或 BufferedReader
这样的工具。
五. 使用不可变数据类型
基本类型及其封装对象类型都是不可变的。
大数中的 BigInteger
和 BigDecimal
也是不可变的。
Java
对 Set
,Map
,List
包装:这种包装器得到的结果是不可变的:只能看,即不能使用 get()
,set()
,remove()
:
Collections.unmodifiableList
Collections.unmodifiableSet
Collections.unmodifiableMap
但是这种“不可变”是在运行阶段获得的,编译阶段无法据此进行静态检查。例如排序时,编译阶段编译器并不会发出警告,只会在运行时发出警告。
六. 测试目标
safe from bugs (SFB)
远离bug
。需要满足:正确性 (目前看起来是正确的)、防御性 (将来也会是正确的);easy to understand (ETU)
易读性。我们不得不和以后有可能需要理解和修改代码的程序员进行交流 (修改BUG
或者添加新的功能),那个将来的程序员或许会是几个月或者几年以后的你,如果你不进行交流,那么到了那个时候,你将会惊讶于你居然忘记了这么多,并且这将会极大地帮助未来的你有一个更良好的设计;ready for change (RFC)
可改动性。软件总是在更新迭代的,一些好的设计可以让这个过程变得非常容易,但是也有一些设计将会需要让开发者扔掉或者重构大量的代码。