Java 字节流与字符流(4)

怎样算是“一个字符”?

目录
[隐藏]

在这一篇,我们谈论最后一个话题,就是“到底怎样才算一个‘字符’”?

其实这个话题在字符集与编码(五)——代码单元及 length 方法中和文本在内存中的编码(1)——乱码探源(4)也有所涉及,这里结合字符流的话题再综合深入探讨它一下,并且还将涉及一个 unicode 组合字符及正规化的话题。(这在前面也没有涉及过的)

怎样算是一个字符?

初看起来,这是个很 naive 的问题。以前面经常举的例子来说:

“h” 是一个字符;

“i” 是一个字符;

“你” 是一个字符;

“好” 也是一个字符。

所谓“字符”,那不就是我们眼睛看到的那一个个抽象的“东西”吗?这好像也没什么难理解的。然而,计算机里的一切都是很具体的,也通常是带有某种限制的。

举个例子来说,我们常说的“一个整数”就是一个抽象的数字,而计算机中,就以 Java 为例吧,最直接对应的概念就是一个 int,这是一个很具体的东西,然后是带有某种限制的,比如只有 32 bit,还分了正负数,所以它能表示的整数是有一个范围的,不是你头脑中想到的每一个整数都能塞到这个 int 里面去。

那么同理,Java 中所谓的‘字符’,包括所谓的‘字符流’这些个概念,严格地讲,这里的‘字符’具体指的是 char,也就是 Java 中的那个基本数据类型 char。

基本数据类型 char 是 16 位的,无符号的(也就是不分正负数,都算正数),所以它也是有范围限制的,撑死了最多也就能表示 2^16,也就是 65536 个不同字符。

可是另一方面我们知道,Unicode 的范围是 U+0000 ~ U+10FFFF(0 ~ 1114111),是百万级别,1114112 这个值可比 65536 大得多了去了。

有很多位置还是空的,但目前已经定义的字符也有 13 万+ 了(截至 2017 年 6 月,具体见 Unicode 10.0 版本中的介绍:http://www.unicode.org/versions/Unicode10.0.0/)。

所以,很明显,char 中根本放不下那么多。char 只能容纳 U+0000 ~ U+FFFF(0 ~ 65535)间的字符,这段范围称为 BMP(基本多语言平面);对于 U+10000 ~ U+10FFFF 间的字符,char 表示力有不逮,心有余而空间不足~

关于 Unicode 的介绍:字符集与编码(四)——Unicode.

一个 BMP 外的字符

比如,前面一再提到的那个音乐符号【】(假如你的系统字符集不支持,这个字符将显示不出来),如下图:

music symbol U+1d11e

它是 U+1D11E(119070) 号字符,大过 U+FFFF,char 这座小庙放不下这尊大神,强行赋值给 char 会导致报错(非法字符常量:Invalid character constant):

java char invalid character constant

这个字符无论是用 utf-8,utf-16 还是 utf-32 都要占用四个字节:

u+1d11e in utf-8, utf-16 and utf-32 encoding

单个 char 虽然无法放置它,但可以用两个连着的 char 放它,这就是所谓的”代理对“了:

string u+1d11e

可以把它放到 String 中,不过这么“一个”字符,它的长度(length)却是 2:

u+1d11e length

关于这些的更详细的解释,可以参考字符集与编码(五)——代码单元及 length 方法中和文本在内存中的编码(1)——乱码探源(4)中的介绍。

字符流的局限性

那么,由以上介绍,我们知道了 char 的局限,它无法跟我们用眼睛看到的“字符”划等号,那么 Java 的字符流也会受到此局限,它们是一脉相承的。确切的讲,所谓的“字符流”更准确的说法是“16 位 char 流”。比如,如果你看一下 Reader 中 read 方法的介绍:

java reader read javadoc

会发现,它每 read 一下,虽然返回是个 int,但值的范围却是受限的,实际上它就是返回一个 16 位的 char。

假如用一个文本文件保存着上述那个字符,编码用 utf-8.

u+1d11e in txt file utf-8

那么它的字节是 4,如果现在用一个字符流去读取它,那么要 read 两次:

java reader non-bmp character

你可能会好奇,这么读一下,它到底读上来几个字节呢?如果这个文本文件是用 utf-16 编码,可能相对好理解一些,read 一下就是返回两字节。但是现在是 utf-8 呢?如果只是读取前面的 f0 9d,是不足以转换成 d8 34 的。事实上,首字节 f0 的二进制为 11110000,是以 11110 开头的,根据 utf-8 编码规则,读到这个字节,就知道要一下读四个字节,这样才算一个完整“字符(码点)”,但返回时还是分了两段,形式上你也要 read 两次,根源还是 char 本身的局限性。

所以,是不是字符流读一下就能返回一个“字符”呢?这主要具体看你怎么定义“字符”这个概念了。

组合字符(composite characters)与正规化问题(normarlize)

事实上,所谓的“一个字符”在考虑到 Unicode 中的所谓组合字符时还会更复杂一些。

具体介绍见:http://www.unicode.org/reports/tr15/

比如下面有两个组合字符:

unicode 组合字符

就以上面那个为例吧,那个上面有个小圆圈的 A。一方面,Unicode 为它单独定义了一个字符(码点),也就是上面显示的【U+00C5】。

另一方面,它又可以由普通的字母 A【U+0041】和一个小圆圈字符【U+030A】组合而成。

跟我们拼音上面的四声音调符号类似。

什么意思呢?来具体看下下面的测试:

unicode 组合字符输出测试

如上,先把三个字符换行依次输出,是三个不同的字符:戴帽子的 A,A 以及小圆帽。

但把后两个字符(也就是普通的 A 和小圆帽)连着输出时,它们会重叠在一起,在眼睛看来似乎成了一个字符,跟前面那个字符一样。除非把它们用空格隔开,这样它们才不会发生组合。

所以,但我们用眼睛看到一个这样的字符时,它的底层可能是一个字符(码点),也可能是两个字符(码点),这无疑会带来一些困扰,不过 Unicode 也提供了所谓的正规化方法:

unicode 组合字符正规化(normalize)

如上,如果直接比较,结果肯定是不等的,但经过正规化(normalize)后,就相等了,与我们眼睛看到的结果一致。

当然了,虽然有这么些个方法,但事情还是变得复杂了。总而言之呢,所谓的“一个字符”其实没有我们想象的那么简单,尽管这些情况都是比较罕见的,多数情况下你还是可以按照常规去理解,但也应该对这些有个了解,这也是这里介绍它们的原因。

关于字节流与字符流的话题就介绍到这里。

《Java 字节流与字符流(4)》有2个想法

  1. “如果只是读取前面的 f0 9d,是不足以转换成 d8 34 的。事实上,首字节 f0 的二进制为 11110000,是以 11110 开头的,根据 utf-8 编码规则,读到这个字节,就知道要一下读四个字节,这样才算一个完整“字符(码点)”,但返回时还是分了两段,形式上你也要 read 两次,根源还是 char 本身的局限性” 是不是可以理解实际上读了4个字节,但是形式上你要read两次?

    1. 可以这么理解。第一次 read 底层实际就要把 utf-8 的 4 字节读上来,这样才能转换为 utf-16 的 4 字节代理对;但第一次 read 只返回高代理对,因为 char 只能收 2 字节,第二次 read 再返回低代理对,但底层实际不需要去读了。

发表评论

电子邮件地址不会被公开。