数据类型和类型检查
前两章回答了:什么是“高质量的软件”、 如何从不同维度刻画软件、软件构造的基本过程和步骤。
本章主要内容是软件构造的理论基础——ADT(抽象数据类型) 和软件构造的技术基础——OOP(面向对象编程)。
目录
- Data type in programming languages
- Static vs. dynamic data types
- Type checking
- Mutability & Immutability
- Snapshot diagram
- Complex data types: Arrays and Collections
- Useful immutable types
- Null references
Data type in programming languages
Types and Variables
数据类型(type)是一组值,以及可以对这些值执行的操作。例如:
- boolean: Truth value (true or false)
- int: Integer (0, 1, -47)
- double: Real number (3.14, 1.0, -2.1)
- String: Text (“hello”, “example”)
变量(Variables):用特定数据类型定义,可存储满足类型约束的值。形式:TYPE NAME
Types in Java
primitive types 基本数据类型,例如:
- int (for integers like 5 and -200, but limited to the range ± 2^31, or roughly ± 2 billion)
- long (for larger integers up to ± 2^63)
- boolean (for true or false)
- double (for floating-point numbers, which represent a subset of the real numbers)
- char (for single characters like ‘A’ and ‘$’ )
object types 对象数据类型,例如:
- String represents a sequence of characters
- BigInteger represents an integer of arbitrary size
按照Java约定,基本类型是小写字母,而对象类型以大写字母开头。
对象类型形成层次结构
所有对象的根结点都是 Object
Boxed primitives
将基本类型包装为对象类型,例如Boolean, Integer, Short, Long, Character, Float, Double。通常是在定义集合类型的时候使用它们。一般情况下,尽量避免使用。一般可以自动转换。
Operations
操作符:执行简单计算的符号。例如+,-,*,/,=。
操作:是获取输入并生成输出的函数(有时会自行更改这些值)。
重载(Overload):同样的操作名可用于不同的数据类型 。
Static vs. dynamic data types
Java是静态类型语言 ,在编译阶段进行类型检查 ,而动态类型语言,例如Python,在运行阶段进行类型检查。
Type checking
Conversion by casting 类型转换
Static Checking and Dynamic Checking
一种语言可以提供的三种自动检查:
- 静态类型检查:该程序甚至在运行之前自动发现该错误
- 动态类型检查:该代码执行时会自动发现该错误
- 无检查:该语言不会帮助您找到错误。 必须亲自检查,否则最终会得到错误的答案。
毫无疑问,静态类型检查 >> 动态 >> 无检查。
静态类型检查
静态类型检查:可在编译阶段发现错误,避免了将错误带入到运行阶段,可提高程序正确性/健壮性。具体分为以下几个错误:
- Syntax errors(语法错误)
- Wrong names (类名/函数名错误)
- Wrong number of arguments (参数数目错误)
- Wrong argument types (参数类型错误)
- Wrong return types (返回值类型错误)
动态类型检查
在运行时检查是否有错,具体分为以下几个错误:
- Illegal argument values (非法的参数值)
- Unrepresentablereturn values (非法的返回值)
- Out-of-range indexes (越界)
- Calling a method on a null object reference(空指针)
Static vs. Dynamic Checking
静态检查:关于“类型”的检查,不考虑值 。
动态检查:关于“值”的检查。
Mutability and Immutability
改变一个变量、改变一个变量的值,二者有何区别?
- 改变一个变量:将该变量指向另一个值的存储空间 。
- 改变一个变量的值:将该变量当前指向的值的存储空间中写入一个新的值。
Immutability 不变性
不变性是一个重要设计原则 。
不变数据类型(Immutable types ):一旦被创建,其值不能改变 。如果是引用类型,也可以是不变的,即一旦确定其指向的对象,不能再被改变 。要使引用不可变,请使用关键字final声明它。如果编译器无法确定final变量不会改变,就提示错误,这也是静态类型检查的一部分。
所以,尽量使用 final变量作为方法的输入参数、作为局部变量。 final表明了程序员的一种“设计决策”。
Note:
- final类无法派生子类
- final变量无法改变值/引用
- final方法无法被子类重写
不变对象:一旦被创建,始终指向同一个值/引用 。
可变对象:拥有方法可以修改自己的值/引用。
String as an immutable type
String s = "a";
s = s.concat("b");//s+="b" and s=s+"b" also mean the same thing
snapshot:
可见,s指向了一个新的引用。
StringBuilder as a mutable type
StringBuilder sb = new StringBuilder("a");
sb.append("b");
snapshot:
可见sb指向的存储空间并没有改变,只是里面存储的值改变了。
上面两个例子尽管最终结果相同,可见当只有一个引用指向该值, mutable和immutable的数据类型并没有区别, 但有多个引用的时候,差异就出现了 。
String t = s;
t = t + "c";
StringBuilder tb = sb;
tb.append("c");
snapshot:
Advantage of mutable types
使用不可变类型,对其频繁修改会产生大量的临时拷贝(需要垃圾回收) ;而可变类型最少化拷贝以提高效率 。使用可变数据类型,可获得更好的性能 ;也适合于在多个模块之间共享数据
既然如此,为何还要用不可变类型?
答案是不可变类型更“安 全”,在其他质量指标上表现更好 。
Risky example #1: passing mutable values
首先看一个计算list中所有整数和的方法
/** @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练习(不要重复自己),实现者编写一个使用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);
}
接着调用main函数
// 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。原因是List中的Integer类型的数据是mutable的,而sumAbsolute函数改变了list中数的正负值,因此之后sum计算的数是改变后的数,而不是原始数据。 这种错误非常难于跟踪和发现 , 对其他程序员来说,也难以理解。
Risky example #2: returning mutable values
我们刚刚看到一个将可变对象传递给函数的示例导致了问题。如果返回一个可变的对象会有怎样的风险呢?
我们来看看Date
,它是内置的Java类之一。 Date
恰好是一个可变类型。
/** @return the first day of spring this year */
public static Date startOfSpring() {
return askGroundhog();
}
这里我们使用众所周知的Groundhog算法来计算春季何时开始(Harold Ramis,Bill Murray等人,土拨鼠日,1993)。
客户开始使用这种方法,例如计划他们的大派对:
// somewhere else in the code...
public static void partyPlanning() {
Date partyDate = startOfSpring();
// ...
}
之后,代码被重写为最多询问一次groundhog,然后将groundhog的答案缓存以备将来调用:
/** @return the first day of spring this year */
public static Date startOfSpring() {
if (groundhogAnswer == null) groundhogAnswer = askGroundhog();
return groundhogAnswer;
}
private static Date groundhogAnswer = null;
其次,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?
}
来看一下snapshot,上面的代码会导致什么错误。
可见,groundHogAnswer和partyDate都指向同一个Date对象,当partyDate改变month时,会导致groundHogAnswer的month也发生改变。
如何防止此类错误呢?可以采用防御式拷贝(defensive copy)
return new Date(groundhogAnswer.getTime());
通过防御式拷贝,给客户端返回一个全新的Date对象 。但是。大部分时候该拷贝不会被客户端修改, 可能造成大量的内存浪费 。因此,如果使用不可变类型, 则节省了频繁复制的代价,由此可以看到不可变类型的好处。
什么时候可以安全地使用可变类型对象呢?
- 局部变量,不会涉及共享
- 只有一个引用
若有多个引用(别名),使用可变类型就非常不安全 。
Snapshot diagram
Snapshot 是一个code-level, run-time, and moment view。用于描述程序运行时的内部状态 ,好处是用于描述程序运行时的内部状态 ,便于刻画各类变量随时间变化 ,便于解释设计思路
Primitive and Object values in Snapshot Diagram
Primitive values 基本类型的值
基本类型的值由bare constants表示。
Object values 对象类型的值
对象类型的值是由其类型标记的圆
不可变对象,用双线椭圆。
String s = "a";
s = s + "b";
不可变引用(Immutable references)
用双线箭头
引用是不可变的,但指向的值却可以是可变的。可变的引用,也可指向不可变的值。
Complex data types: Arrays and Collections
Array
数组是另一个类型T的固定长度序列。
int[] a = new int[100];
int []数组类型包含所有可能的数组值,但是一旦创建了特定的数组值,永远不会改变其长度。
数组类型的操作包括:
- indexing:
a[2]
- assignment:
a[2]=0
- length:
a.length
List
List是另一个类型T的可变长度序列。
List<Integer> list = new ArrayList<Integer>();
List的操作包括:
- indexing:
list.get(2)
- assignment:
list.set(2, 0)
- length:
list.size()
需要注意的是List是一个接口,在List中的成员必须是一个对象object。
Iterating
- 遍历
Array
int max = 0;
for (int i=0; i<array.length; i++) {
max = Math.max(array[i], max);
}
- 遍历
List
int max = 0;
for (int x : list) {
max = Math.max(x, max);
}
Set
Set是零个或多个唯一对象的无序集合。Set是一个抽象接口。
Map
A Map is similar to a dictionary (key-value) ,Map是一个抽象接口。
创建List, Set, and Map变量
List<String> firstNames = new ArrayList<String>();
List<String> lastNames = new LinkedList<String>();
List<String> firstNames = new ArrayList<>();
List<String> lastNames = new LinkedList<>();
Set<Integer> numbers = new HashSet<>();
Map<String,Turtle> turtles = new HashMap<>();
遍历:
List<String> cities = new ArrayList<>();
Set<Integer> numbers = new HashSet<>();
Map<String,Turtle> turtles = new HashMap<>();
for (String city : cities) {
System.out.println(city);
}
for (int num : numbers) {
System.out.println(num);
}
for (int ii = 0; ii < cities.size(); ii++) {
System.out.println(cities.get(ii));
}
for (String key : turtles.keySet()) {
System.out.println(key + ": " + turtles.get(key));
}
迭代器(Iterator)
迭代器是一个mutable的对象,它可以逐步收集元素并逐个返回元素。
iterator有两种方法:
next()
:返回集合中的下一个元素—这是一个mutator方法!hasNext()
:测试迭代器是否已达到集合的末尾。
Useful immutable types
基本类型及其封装对象类型都是不可变的。
不要使用可变的Date
,根据您需要的计时粒度,使用java.time
或java.time.ZonedDateTime
中适当的不可变类型。
Java的集合类型(List,Set,Map
)的通常实现都是可变的:ArrayList,HashMap
等。
Collections
实用程序类具有获取这些可变集合的不可修改视图的方法:
Collections.unmodifiableList
Collections.unmodifiableSet
Collections.unmodifiableMap
这种包装器得到的结果是不可变的:只能看,但是这种“不可变”是在运行阶段获得的,编译阶段 无法据此进行静态检查。
Null references
在Java中,对对象和数组的引用也可以采用特殊值Null,这意味着引用不指向对象。 空值是Java类型系统中的一个不幸的漏洞。
基本数据类型元素不能为null,编译器会拒绝这种带有静态错误的尝试:
int size = null; //illegal
可以将null分配给任何非原始变量,并且编译器在编译时高兴地接受这些代码。 但是你会在运行时遇到错误,因为你不能调用任何方法或者使用带有这些引用之一的任何字段(抛出NullPointerExceptions
)。
String name = null; name.length();
int[] points = null; points.length;
null与空字符串“”或空数组不同