憨憨呆呆的IT之旅

我见,我思,我行

世上唯一不变的是变化。

相对于其他实物,软件从写下第一行代码开始就不断反复变化。为了应对各种变化,需要一些通用的设计原则和模式来指导日常开发。
面向对象诞生至今早已超过半个世纪,大量前辈们早就探索和总结出了一些列设计原则和设计模式。学好面向对象开发好比学好一门武功,设计原则是内功心法,设计模式是招式套路。心法和套路相辅相成,都是不可或缺的一环。

面向对象有七个设计原则:

  1. 开放封闭原则
  2. 里氏替换原则
  3. 依赖倒置原则
  4. 接口隔离原则
  5. 迪米特法则(最少知道原则)
  6. 单一职责原则
  7. 合成复用原则
    下面分别简单描述一下。

开放封闭原则 OCP

这个原则是所有面向对象原则的核心。
任何一个软件实体(小到一个类、大到一个系统)都要对扩展开放,对修改封闭。换句话说,要在尽量不修改原有代码的基础上,对功能进行扩充。
一般实践过程中,常常通过接口、抽象类定义抽象层,然后通过实现类进行功能扩展。每次增加新功能只需要增加一个实现类即可。

里氏替换原则 LSP

所有引用基类的地方必须能透明地使用其派生类的对象。说人话,就是所有父类引用都可以换成子类的引用。
违反里氏替换原则的一个小例子:

1
2
3
4
5
if (obj typeof SubClass1) {
// do a
} else if (obj typeof SubClass2) {
// do b
}

依赖倒置原则 DIP

这个原则有两层含义:

  • 高层模块不应依赖于低层模块,二者都应依赖于抽象。
  • 抽象不应依赖于细节,细节应依赖于抽象
  • 针对接口编程,不要针对实现编程

我们在编码中,应该尽量依赖于抽象类和接口,而不是实现类。

接口隔离原则 ISP

不能强迫用户去依赖他们不使用的接口。
换句话说,接口里包含的方法定义应尽可能少! 大接口要拆分成小接口。

迪米特法则(最少知道原则) Law of Demeter ,LoD

只与你直接的朋友们通信,不要跟“陌生人”说话。

一个实体应尽可能少地与其他实体发生相互作用,使系统各模块相互独立。

单一职责原则 SIP

这个原则是主要针对类来说的,让一个类只专注于一个职责。 如果有多个职责怎么办,通过其他原则拆分、重构它!
所谓类的职责,是指引起该类变化的一个原因。

合成复用原则 Composite/Aggregate Reuse Principle ,CARP

多使用组合/聚合,少使用类继承。

参考资料

  1. 面向对象设计的七大设计原则详解

Stream接口概念辨析

Java中有两类Stream,一类是IO流,常见的有InputStream、OutputStream等,还有一类是Java8 新增的Stream接口
Stream接口位于java.util.stream包中,是对集合功能的增强。
如何增强呢? 简单来说,它支持集合元素的筛选、切片、映射、排序、匹配查找、聚合等多种复杂常见操作,使用Lambda表达式简化代码编写,并支持并行和串行两种模式的操作。

Stream接口使用步骤

  1. 创建Stream,通过一个数据源来获取一个流
  2. 转换Stream,每次转换可得到一个新的Stream对象
  3. 对Stream进行聚合操作产生最终结果

Stream流的创建

有以下四种创建方式:

  1. 基于现有集合,调用集合的stream()方法创建:
    1
    2
    List<Integer> integerList = List.of(1, 2, 3, 4, 5, 6);
    Stream<Integer> integerStream = integerList.stream();
  2. 基于数组,通过Stream工具类的stream()静态方法创建:
    1
    IntStream intStream = IntStream.of(1, 2, 3, 4, 5, 6);
  3. 通过Stream接口的of(T.. values)静态方法创建:
    1
    Stream<Integer> integerStream1 = Stream.of(1, 2, 3, 4, 5, 6);
  4. 通过Stream接口的generate(Supplier<? extends T> s)静态方法创建:
    1
    Stream<Integer> limit  = Stream.generate(new Random()::nextInt).limit(10);

Stream中间操作(结果仍为Stream)

  • 筛选、切片
方法声明功能
Stream filter<Predicate<? super T> predicate)过滤,返回一个包含匹配元素的流
Stream distinct()去重,返回不含重复元素的流
Stream limit(long maxSize)切片,返回不超过maxSize数量的元素组成的流
Stream skip(long n)切片,返回丢弃前n个元素后的流
  • 映射
方法声明功能
Stream map(Function<? super T, ? extends R> mapper)返回每个处理过元素组成的流
Stream flatMap(Function<? super T,? extends Stream<? extends R>> mapper)返回每个被替换过元素组成的流,并将所有流合成一个流
  • 排序
方法声明功能
Stream sorted()返回经过自然排序后元素组成的流
Stream sorted(Comparator<? super T> comparator)返回经过比较器排序后元素组成的流

Stream终止操作

  • 匹配与查找
方法声明功能
Optional findFirst()返回该流的第一个元素
boolean allMatch(Predicate<? super T> predicate)判断所有元素是否匹配
boolean noneMatch(Predicate<? super T> predicate)判断没有元素是否匹配
Optional max(Comparator<? super T> comparator)根据比较器返回最大元素
Optional min(Comparator<? super T> comparator)根据比较器返回最小元素
long count()返回元素的个数
void forEach(Consumer<? super T> action)对流中每个元素执行操作
  • 规约(reduce)

    方法声明功能
    Optional reduce(BinaryOperator accumulator)返回结合后的元素值
  • 收集

    方法声明功能
    <R,A> R collect(Collector<? super T,A,R> collector)使用收集器对元素进行处理

while(true) + scanner.nextInt() 导致InputMismatchException 死循环问题

今天做一个练手项目时,需要用户输入数字来选择菜单。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
protected void processOption() {
System.out.println("\n\n\t\t在线考试系统");
System.out.println("---------------------------------");
System.out.println("\t[1] 学员登录\t\t[2] 管理员登录");
System.out.println("\t[0] 退出系统");
System.out.print("请选择操作:");
int choice = scanner.nextInt();
System.out.println("您的选择是: " + choice);
// 后续处理
System.out.println("后续处理逻辑。。。");
}

显然,上面这段代码,用户只有一次输入机会。为提高程序可用性,如果用户输入的不是数字,需要重新输入直到输入数字为止。因此考虑加上循环,修改后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
protected void processOption() {
while(true) {
System.out.print("请选择操作:");
int choice = scanner.nextInt();
System.out.println("您的选择是: " + choice);
if (choice >=0 && choice <=2) {
// 输入的选项合法,跳出循环
break;
}
}
// 后续处理
System.out.println("后续处理逻辑。。。");
}

如果用户正常输入数字,则没有问题。否则,若输入字母、其他字符等,则直接抛出运行时异常InputMismatchException,导致程序异常终止。

1
2
3
4
5
6
7
8
9
10
11
12
请选择操作:试试水
客户端关闭!
Exception in thread "main" java.util.InputMismatchException
at java.base/java.util.Scanner.throwFor(Scanner.java:939)
at java.base/java.util.Scanner.next(Scanner.java:1594)
at java.base/java.util.Scanner.nextInt(Scanner.java:2258)
at java.base/java.util.Scanner.nextInt(Scanner.java:2212)
at homework1.client.ClientView.processOption(ClientView.java:39)
at homework1.client.ClientBaseView.mainPage(ClientBaseView.java:49)
at homework1.test.ClientTest.main(ClientTest.java:23)

Process finished with exit code 1

为避免因为这么一个小小错误引起程序终止,增加try-catch异常捕获:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected void processOption() {
while(true) {
try {
System.out.print("请选择操作:");
int choice = scanner.nextInt();
System.out.println("您的选择是: " + choice);
if (choice >=0 && choice <=2) {
// 输入的选项合法,跳出循环
break;
}
} catch (InputMismatchException e) {
System.out.println("输入错误!请输入数字选项。");
}
}
// 后续处理
System.out.println("后续处理逻辑。。。");
}

输入字符串进行测试,发现程序并未如预期般循环等待输入,而是直接陷入死循环:

1
2
3
4
5
6
7
请选择操作:输入错误!请输入数字选项。
请选择操作:输入错误!请输入数字选项。
请选择操作:输入错误!请输入数字选项。
请选择操作:输入错误!请输入数字选项。
请选择操作:输入错误!请输入数字选项。
请选择操作:输入错误!请输入数字选项。
...

问题原因分析

为什么呢?翻一下Scanner的源码,发现nextInt()方法在识别到非数字字符串时,会抛出这个Input异常,并且在抛出异常前刻意把缓冲区指针设置到本字符串的开头位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// java.util.Scanner.java 
public int nextInt(int radix) {
// Check cached result
if ((typeCache != null) && (typeCache instanceof Integer)
&& this.radix == radix) {
int val = ((Integer)typeCache).intValue();
useTypeCache();
return val;
}
setRadix(radix);
clearCaches();
// Search for next int
try {
String s = next(integerPattern());
if (matcher.group(SIMPLE_GROUP_INDEX) == null)
s = processIntegerToken(s);
return Integer.parseInt(s, radix);
} catch (NumberFormatException nfe) {
position = matcher.start(); // don't skip bad token
throw new InputMismatchException(nfe.getMessage());
}
}

重点看最后三行:

1
2
3
4
} catch (NumberFormatException nfe) {
position = matcher.start(); // don't skip bad token
throw new InputMismatchException(nfe.getMessage());
}

也就是说,在我们的程序中,如果用nextInt()接收一个非数字字符串输入,字符串并不会被消费掉。下次再接收时,仍然会读取到跟上次一样的字符串。这样程序就陷入了死循环。

处理方案一:把错误字符串消费掉

Scanner没有帮我们做的事,我们可以自己做。在Catch代码块里调用一下next()方法,把错误字符串消费掉即可。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected void processOption() {
while(true) {
try {
System.out.print("请选择操作:");
int choice = scanner.nextInt();
System.out.println("您的选择是: " + choice);
if (choice >=0 && choice <=2) {
// 输入的选项合法,跳出循环
break;
}
} catch (InputMismatchException e) {
System.out.println("输入错误!请输入数字选项。");
scanner.next(); // 增加这一句,消费掉输入错误的字符串
}
}
// 后续处理
System.out.println("后续处理逻辑。。。");
}

执行效果如下:

1
2
3
4
5
6
7
请选择操作:xasx
输入错误!请输入数字选项。
请选择操作:afdsfas
输入错误!请输入数字选项。
请选择操作:1
您的选择是: 1
后续处理逻辑。。。

解决!

处理方案二:nextInt()换成next()

既然nextInt()方法有这些毛病,我们也可以放弃它,改用next()方法接收到字符串,然后自己针对字符串做处理。 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protected void processOption() {
while(true) {
System.out.print("请选择操作:");
String temp = scanner.next();
int choice = -1;
boolean match = temp.matches("[0-9]+");
if (match) {
int choice = Integer.parseInt(temp);
System.out.println("您的选择是: " + choice);
if (choice >=0 && choice <=2) {
// 输入的选项合法,跳出循环
break;
} else {
continue;
}
} else {
System.out.println("输入错误!请输入数字选项。");
continue;
}
}
// 后续处理
System.out.println("后续处理逻辑。。。");
}

小结

两种方案都可以处理掉while(true) + nextInt()导致的InputMismatchException死循环问题,相对来说,第一种方案代码更简洁一些。

我们一般使用ObjectInputStreamObject readObject()方法来从输入流中读出一个对象,这个方法有个缺陷是,无法通过返回值来判断是否读到了文件末尾。因此,我们要序列化多个对象时,需要额外一些小技巧来处理。大体来说,有四种方法能够正确序列化反序列化多个对象:

  • 把对象装入集合中,对整个集合进行序列化和反序列化
  • 把对象放入对象数组中,对对象数组进行序列化和反序列化
  • 依次序列化写入多个对象,并追加一个null对象,反序列化读取时若读到null就停止
  • 依次序列化写入多个对象,读取时以FileInputStream的int available()返回值判断是否终止
    下面举例说明。

数据准备&测试程序准备

首先准备一个要序列化的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class User implements Serializable {
private static final long serialVersionUID = 1105545465577482303L;
private String name;
private int age;
private transient String phone; // transient 关键字表示该成员不参与序列化

public User() {
}

public User(String name, int age, String phone) {
this.name = name;
this.age = age;
this.phone = phone;
}

@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
", phone='" + phone + '\'' +
'}';
}

然后准备测试程序,主体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class SerializableTest {
public static void main(String[] args) {
// 1. 准备要序列化的数据
User[] users = {
new User("张三",33, "185000011133"),
new User("李四",44, "185000011144"),
new User("王五",55, "185000011155"),
new User("赵六",66, "185000011166"),
};
ObjectOutputStream oos = null;
ObjectInputStream ois = null;
try {
// 2. 准备输出流和输入流
String fileName = "./out/oos_1.txt";
oos = new ObjectOutputStream(new FileOutputStream(fileName));
FileInputStream fis = new FileInputStream(fileName);
ois = new ObjectInputStream(fis);
// 3. 序列化到文件,并反序列化读取并输出
// testSerialize1(users, oos, ois);
// testSerialize2(users, oos, ois);
testSerialize3(users, oos, ois);
// testSerialize4(users, oos, ois, fis);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
} finally {
// 4. 关闭流
if (null != ois) {
try {
ois.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (null != oos) {
try {
oos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}

实现多个对象的序列化和反序列

下面分别用4种方式实现多个对象的序列化和反序列化。

1. 把对象装入集合中,对整个集合进行序列化和反序列化(推荐)

把下面这个方法加入到上面的SerializableTest类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 方法1: 使用集合保存多个对象,对集合进行序列化反序列化
* @param users
* @param oos
* @param ois
*/
private static void testSerialize1(User[] users, ObjectOutputStream oos, ObjectInputStream ois) throws IOException, ClassNotFoundException {
ArrayList<User> list1 = new ArrayList<>();
for (User user : users) {
list1.add(user);
}
oos.writeObject(list1);
oos.flush();
System.out.println("方法1写入完毕!");
Object obj = ois.readObject();
System.out.println("方法1读取数据为:" + obj);
ArrayList<User> list2 = (ArrayList<User>)obj;
for (User user: list2) {
System.out.println(user);
}
}

运行测试程序,输出为:

1
2
3
4
5
6
方法1写入完毕!
方法1读取数据为:[User{name='张三', age=33, phone='null'}, User{name='李四', age=44, phone='null'}, User{name='王五', age=55, phone='null'}, User{name='赵六', age=66, phone='null'}]
User{name='张三', age=33, phone='null'}
User{name='李四', age=44, phone='null'}
User{name='王五', age=55, phone='null'}
User{name='赵六', age=66, phone='null'}

2. 把对象放入对象数组中,对对象数组进行序列化和反序列化

把下面这个方法加入到上面的SerializableTest类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 方法2: 使用Object数组保存多个对象,对整个数组进行序列化反序列化
* @param users
* @param oos
* @param ois
*/
private static void testSerialize2(User[] users, ObjectOutputStream oos, ObjectInputStream ois) throws IOException, ClassNotFoundException {
oos.writeObject(users);
oos.flush();
System.out.println("方法2写入完毕!");
Object[] objs = (Object[])ois.readObject();
System.out.println("方法2读取数据为:");
for (Object obj: objs) {
System.out.println(obj);
}
}

输出跟上面类似。

3. 依次序列化写入多个对象,并追加一个null对象,反序列化读取时若读到null就停止

把下面这个方法加入到上面的SerializableTest类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 方法3: 依次写入多个对象,再追加一个null对象表示结束,读取时判断读到的对象是否为null
* @param users
* @param oos
* @param ois
*/
private static void testSerialize3(User[] users, ObjectOutputStream oos, ObjectInputStream ois) throws IOException, ClassNotFoundException {
for (User user: users) {
oos.writeObject(user);
}
oos.writeObject(null);
oos.flush();
System.out.println("方法3写入完毕!");

System.out.println("方法3读取数据为:");
Object obj = null;
while ((obj = ois.readObject()) != null) {
System.out.println(obj);
}
}

输出同方法2。

4. 依次序列化写入多个对象,读取时以FileInputStream的int available()返回值判断是否终止

把下面这个方法加入到上面的SerializableTest类中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 方法4: 依次写入多个对象,读取时使用fis.available()方法判断是否读取到末尾
* @param users
* @param oos
* @param ois
* @param fis
*/
private static void testSerialize4(User[] users, ObjectOutputStream oos, ObjectInputStream ois, FileInputStream fis) throws IOException, ClassNotFoundException {
for (User user: users) {
oos.writeObject(user);
}
oos.flush();
System.out.println("方法4写入完毕!");

System.out.println("方法4读取数据为:");
while (fis.available() > 0) {
Object obj = ois.readObject();
System.out.println(obj);
}
}

输出同方法2。

.DS_Store文件是Mac系统用来存储当前文件夹的显示属性元数据的,如图标位置等设置。.DS_Store广泛存在于每个文件夹下,一般供Finder、Spotlight使用。而对于基于Git托管的代码仓库来说,.DS_Store就是无用数据了。我们需要在Git中忽略它们。

忽略.DS_Store文件,我们通常有两种选择:

1. 当前项目忽略

在当前项目根目录的.gitignore文件中添加如下一行即可:

1
2
# vim .gitignore
**/.DS_Store

.gitignore文件常用规则可以看这里

对于已经在Git版本控制的中.DS_Store,希望Git能够忽略,但不删除本地文件,需要在terminal中输入以下命令:

1
git rm -r --cached .DS_Store

Git如何屏蔽已经加入版本控制的文件可以看这里

这种方式每当开新项目时都要配置,用起来比较麻烦。

2. 全局忽略

首先创建一个 ~/.gitignore_global文件:

1
touch ~/.gitignore_global

然后在文件中添加如下内容:

1
2
# vim ~/.gitignore_global
**/.DS_Store

最后,将.gitignore_global作为Git的全局忽略配置文件:

1
git config --global core.excludesfile ~/.gitignore_global

这种方式一劳永逸,不用每个项目都单独配置了。

.gitignore文件是Git的忽略配置文件,几乎每个Giter都需要跟这个文件打交道,有必要熟悉并记住它的一些常用规则。具体罗列如下:

  • 所有空行或注释符号#开头的行都被忽略不计
  • 规则按顺序从前到后依次生效
  • 第一个/匹配Git项目根目录
  • /结尾表示匹配的是目录
  • 通配符*可匹配任意多个字符,通配符?匹配单个字符。注意:通配符不会匹配路径分隔符/
  • 两个连续星号**有特殊含义:
    • **/开头表示匹配所有目录下的,例如**/readme.md匹配所有目录下的readme.md文件。
    • /**结尾表示匹配目录下的所有内容,例如a/**匹配目录a下的所有文件和目录、子目录等。
    • a/**/b表示匹配目录a到目录b之间的0或多层目录,例如a/**/b可匹配 a/b, a/x/b,a/x/y/b等。
  • 以惊叹号!开头表示不忽略,即不忽略匹配到本行规则的文件或目录。一般用于在前面规则里被忽略了,但是又想加回到版本控制的文件或目录。注意:如果匹配到的父目录还是忽略状态,则本文件或目录保持忽略状态。

在Mac系统的Finder(访达)里边,每次新建文件夹,或者拖入一个文件夹,文件夹都是停留在光标位置,甚至遮挡其他文件,显得很乱。

image-20200630094340546

今天找到一个终极解决办法,可以让文件和文件夹自动排列整齐。
设置步骤如下:

  1. 打开Finder的齿轮⚙小图标,点击查看显示选项

image-20200630094617334

  1. 修改下图中的分组方式排序方式取值,全部改为**”名称”**:

    修改前:

    image-20200630094822493

    修改后:

    image-20200630095023309

  2. 一定要点击底部的“用作默认”按钮。

    snapshot_2020-06-30 上午9.51.06

经过以上简单三步设置就可以了。
看一下设置后的效果:

image-20200630095619431

在Mac系统中,截图是一件极其简单的事。无需安装任何额外软件,直接使用command+shift+4就可以用光标框选任意屏幕大小进行截图了,截图会自动保存到桌面上。
用户操作简单,通常意味着Mac系统默默帮我们做了很多事情。如果是中文系统,默认截图文件命名为类似“截屏 2020-06-30 上午 08.54.32”这样的格式,对于轻微强迫症的我来说,**”截屏”两个汉字做前缀稍微觉得不喜,可以按如下命令修改为“snapshot_”**:

1
defaults write com.apple.screencapture name "snapshot_"

这样以后的截图文件名就改为“snapshot_2020-06-30 上午 08.54.32”这样的格式了。
如果对后面的日期格式也不满意,还可以去掉日期:

1
defaults write com.apple.screencapture "include-date" 0

这样截图文件命名将变成“snapshot_1”“snapshot_2”,后面的数字按截图先后顺序依次递增。

Unicode和UTF-8是程序员经常遇到的词汇,基本上涉及文字处理的程序,都离不开这两个概念。可是,一会儿Unicode,一会儿又是UTF-8,它们之间到底是什么关系, 你弄明白了吗?
为了搞懂这个问题,有一些基本概念需要提前安利一下。

计算机只能直接处理数字

我们知道,计算机能直接处理的只有二进制数字,因为CPU的基本功能是进行数字的加减乘除四则运算、与或非等逻辑运算、算数和逻辑移位操作、比较数值、变更符号,以及计算主存地址等操作。所有其它数据,比如文本、图
像、音视频等,都需要先转化成数字才能被CPU进行处理。

字符

人类的语言和文字由一个个字符构成,字符包括文字(比如英文字母、汉字)、标点符号以及其他符号等。每种语言和文字都有自己的字符,全世界的字符加起来有好几百万种。

字符编码

由于计算机只能处理二进制数字,而我们人类文字却由字符组成。需要一种编码标准,为每一个字符指定一个二进制数字,来代替字符输入和存储到计算机中。

ASCII编码

ASCII编码是最初的编码标准。它极其简陋,只有128个字符编码,规定了英语26个字母字符和空格、逗号等其他一些常用字符的二进制编码。

ASCII码的局限

ASCII编码对于英语来说足够了。但是世界上语言这么多,每种语言又有几百到几千甚至几万个字符,ASCII码不足以表示这么多字符,于是各个国家都先后制定了自己的编码标准。这些标准都是在ASCII码基础上做了大规模的扩充,兼容ASCII码没问题,但是相互之间就不兼容了。因为对于不同的编码标准,同一个二进制编码可能代表了不同的字符,而相同的字符在不同编码标准中所对应的二进制编码也不一样。这就产生了大量的转换难题。乱码问题就是因为编码识别错误,或转换错误造成的。

计算机系统编码的局限

一般的计算机操作系统,只能支持两种编码混用,一种是ASCII编码,另一种是本地语言编码。计算机系统不支持多种编码的混用。比如同时使用中文GBK、中文繁体BIG5、日文Shift_JIS等。

Unicode

想象一下,如果有一种编码,能够包含地球上的所有文字符号,并指定唯一编码,那前面提到的多种编码转识别难题就迎刃而解了。Unicode就是为了解决这种各自为政的混乱局面产生的。Unicode是一种字符编码方案,包括字符集和字符编码表。它囊括了世界上的所有符号,为每种语言的每个字符都设置了一个独一无二的二进制编码。

Unicode的表示问题

由于Unicode意图囊括世界上所有字符(目前有100多万个字符),它必然需要一个很大的字符集。这个字符集的二进制整数范围很广,像ASCII那样的1个字节是容纳不了的。需要两个字节的二进制数字才能完全容纳。一旦多于一个字节,就需要考虑存储和传输问题了。相关问题有二:

  1. 给定一个字符序列,计算机如何知道这是由多个字节组成的Unicode字符,还是单个字节组成的几个ASCII字符?
  2. 一般的英文字符和数字,只需要一个字节就能表示,而Unicode却规定了至少两个字节,如果所有英文字符都按双字节存储,会造成大量的存储空间浪费。
  3. 给定一个双字节的Unicode字符,在存储和传输时,第一个字节在前,还是第二个字节在前?即Big Endian和Little Endian问题。

Unicode编码表只是规定了字符和两个字节二进制数字之间的逻辑对应关系,并没有规定这个二进制数字应该怎么存储和传输。

UTF-8

为了解决上述几个问题,UTF-8编码产生了。确切的说,UTF-8编码是Unicode的一种编码实现方式。除了UTF-8,还有UTF-16,UTF-32等。

UTF-8一个最大的特点是,它是一种变长的编码方式。它使用1-4个字节来表示一个字符。
UTF-8只有两条简单的编码规则:

  1. 对于单字节字符,字节首位为0,后7位为这个字符对应的unicode二进制数字编码。这部分其实就是ASCII码。
  2. 对于n字节(n>1)字符,第一个字节的前n位为1,第n+1位为0;后面的第2-第n个字节的前两位为10。每个字节除了刚才指定的这几个位之外,其余用字符的unicode二级制数字码依次填充。
    编码规则用图表表示如下:
Unicode范围(16进制)UTF-8编码方式
000000 - 00007F0xxxxxxx
000080 - 0007FF110xxxxx 10xxxxxx
000800 - 00FFFF1110xxxx 10xxxxxx 10xxxxxx
010000 - 10FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

举个例子:“汉”字的Unicode编码是0x6C49。0x6C49在0x0800-0xFFFF之间,使用3字节模板:1110xxxx 10xxxxxx 10xxxxxx。将0x6C49写成二进制是:0110 1100 0100 1001, 用这个比特流依次代替模板中的x,得到:11100110 10110001 10001001,用16进制表示是E6 B1 89。

由UTF-8编码反向解读出二进制数字也很简单。从第一个字节开始判断,如果第一个字节的第一位为0,则这个字节单独构成一个字符;而如果第一个字节是1,往后数连续有几个1,则表示这个字符占用了连续几个字节。

UTF-8、UTF-16、UTF-32

除了最常用的UTF-8编码实现外,Unicode字符集还可以采用UTF-16、UTF-32等编码实现方式。
下面用一个例子简答解释一下。
例如,“汉字”对应的Unicode编码是0x6c49和0x5b57,分别用三种编码表示为:

1
2
3
char8_t  data_utf8[]={0xE6,0xB1,0x89,0xE5,0xAD,0x97}; //UTF-8编码
char16_t data_utf16[]={0x6C49,0x5B57}; //UTF-16编码
char32_t data_utf32[]={0x00006C49,0x00005B57}; //UTF-32编码

字节序 Big Endian 和 Little Endian

字节序有两种,Big Endian大端序和Little Endian小端序,分别简写为BE和LE。对于一个双字节字符来说,如果第一个字节在前面就是大端序,如果第二个字节在前面就是小端序。
根据字节序的不同,UTF-16可被实现为UTF-16BE和UTF-16LE,UTF-32可被实现为UTF-32BE和UTF32-LE。举例说明:
例如,汉字的“汉”,Unicode编码为0x6c49,分别表示为:

Unicode编码UTF-16BEUTF-16LEUTF-32BEUTF-32LE
0x006c496c 4949 6c00 00 6c 4949 6c 00 00
0x020c30d8 43 dc 3030 dc 43 d800 02 0c 3030 0c 02 00

那么,计算机如何知道某个文件到底使用哪种字节序呢?
Unicode标准建议使用BOM(Byte Order Mark)来区分字节序。在传输字节流之前,先传输被作为BOM字符的“零宽无中断空格”(zero width no-break space)字符,用一个未定义的编号FEFF表示。正好是两个字节。
各种UTF编码的BOM如下:

UTF编码Byte Order Mark
UTF-8 without BOM
UTF-8 with BOMEF BB BF
UTF-16LEFF FE
UTF-16BEFE FF
UTF-32LEFF FE 00 00
UTF-32BE00 00 FE FF

根据BOM就能识别出正确的字节序,从而得到正确的编码方式了。
注意,UTF-8的编码方式,其实是规定好了字节顺序的,因此BOM不是必须的。一般不建议在UTF-8文件中加BOM。

IO流的概念和分类

什么是流

读写数据时像流水一样,从一端到另一端,因此叫做“流”。

流的分类

按处理内容分为:

  • 字节流
  • 字符流。 (双字节)

按输入输出类别分为:

  • 输入流
  • 输出流

按跟数据源的关系分为:

  • 节点流
  • 处理流

IO流的体系结构

主要的IO流类可以用下面一张表整合:

分类字节输入流字节输出流字符输入流字符输出流
抽象基类InputStreamOutputStreamReaderWriter
访问文件FileInputStreamFileOutputStreamFileReaderFileWriter
访问数组ByteArrayInputStreamByteArrayOutputStreamCharArrayReaderCharArrayWriter
访问管道PipedInputStreamPipedOutputStreamPipedReaderPipedWriter
访问字符串StringReaderStringWriter
缓冲流BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriter
转换流InputStreamReaderInputStreamWriter
对象流ObjectInputStreamObjectOutputStream
FilterInputStreamFilterOutputStreamFilterReaderFilterWriter
打印流PrintStreamPrintWriter
推回输入流PushbackInputStreamPushbackReader
特殊流DataInputStreamDataOutputStream

我们常用需要掌握的结构:
继承结构图

0%