从一个String转Int说字符集与编码方式
从String转Int说字符编码问题
说起来,创业公司招人还是比较困难的。带我入行的那位老哥被各种培训班速成的”三年经验”折磨地痛不欲生之后,喜欢现让投简历的线上写一段String转int的代码,如果写的还行才招来面试。刚开始我觉得这个问题很简单,最近不知道怎么回事,又想到了这个问题,忽然觉得这个问题很有意思,牵扯的知识还不少,可以稍微总结一下。
并无新意的基本思路
基本思路其实很简单,逐个扫描String的字符,除允许第一个字符为-以外,判断其余字符是否落在0-9之间,如果不在就抛错,如果在的话就处理。如果字符串表示的数字太大、或太小(超出了Int所能表示的最大范围),就抛错。
细节中潜伏的魔鬼
怎么取第N个字符
这个问题很简单啊,cpp的话,直接string[n],Java的话,直接string.charAt(n),不就行了。确实如此,但这些方法其实已经屏蔽了取值的细节,当我们把视野放到二进制层面去的时候,事情就不太一样了。”12345″这样的字符串,到底在内存中、二进制层面是怎么表达的?怎么取法?
科班的同学都是从C语言学起,C里面并没有String这种包装类,一般都只能用char[]来表示,那么”12345″就是5个char,在内存里,每个char占据8个bit,于是取第n个数,就直接char[n]就好,在内存里是拿出第n个8位数据。
但作为中文编程社区的人,显然都经历过被编码和字符集搞疯的时刻。同样,这个问题如果考虑到编码问题,那就要复杂一些。
字符集与编码方式
说到这里,想起以前看《程序员的自我修养》那本书时深受触动的一个观点:无论是什么高级编程语言,最终都是要转换为二进制的电流,运行在电路板上。我一直感觉,只要始终牢记这一点,那么所有计算机世界的问题都可以往下深挖,直到二进制和电路板层面,很多问题也会因为这个思想而找到解决思路。因为计算机只识别二进制,因此从根本上讲,它只能识别”数字”,0是0,1是1,2是10,3是11。计算机天然并不识别’a’,’b’,’c’这样的字符,为了让计算机把数字识别为abc,人类创建了一个叫做字符集的东西,字符集就指定了每个字符对应的数字,比如a对应数字42,b对应数字43。
计算机诞生于英语系国家,所以最早的字符集设计也只考虑了英语的应用,一个特别简单的字符集就设计出来了,这个字符集很小,只包含了26个英文字母的大小写、一些常用标点符号和空白字符,包括空格、tab、回车等,这就是Ascii字符集。因为字符集很小,所以这些字符对应的数字只需要8个bit(实际上还只用了7个bit)就可以完全表示,采用ASCII字符集的char[]在内存里就是8个bit一个紧密排列的。
但随着计算机和互联网走向世界,世界各国的语言远远超过了ASCII字符集的表达范围,比如中文光常用字符就有3000个,全部文字有十万多个,8个bit最多只能指定256个字符,显然不可能表达中文。为了能表达世界各地的语言,各国(地区)的计算机专家先后创建了各个国家和地区的字符集。比如,为了表示中文,中国大陆地区创建了GB2312字符集,随后又扩展到GBK字符集,随后又扩展为GB 18030字符集,台湾地区则创建了BIG5字符集。
这些字符集都做了一件事情:给各自语言中的字符赋予了对应的数字,使得计算机可以保存它。(但大部分字符集同时还做了一件事情,即规定这些数字在内存里怎么存储,即同时指定了编码方式)
随后,世界各国和地区的字符集越来越多,给交流造成了很大的障碍。毕竟,如果A字符集指定a对应数字42,B字符集指定a对应数字142,那一台使用了A字符集的电脑,就无法正确识别B字符集下的文本。随着互联网的发展,这个问题越发突出,为解决这个问题,一群大佬创建了UNICODE字符集,这个字符集旨在容纳地球上的所有字符,即为每一个字符都指定一个对应的数字,以期实现字符集的大一统。目前unicode还在频繁更新,已经容纳了十三万个字符,甚至我们常用的emoji表情,都有了自己的unicode码。
在字符集的相关概念里,有一个概念经常被提起需要引起大家的注意:
- 代码点,也叫码点,Code Point
即一个字符对应的数字。比如”严”字在Unicode中对应的码点是
U+0x4E25
到这里,问题似乎解决了。但其实并没有。字符集只是指定了一个字符对应的数字,但这样的数字怎样在转化为二进制在内存里表示呢?
编码方式
在使用ASCII字符集的年代,每个字符对应的数字怎么在二进制层面表示,这个问题很好解决,因为本来它本来很小,用8个bit完全可以表示,因此,每个数字都表达为8bit的数字就可以了。
但随着字符集的扩展,比如说Unicode字符集,至今已经有13W个字符,显然8bit已经不可能容纳,比如找到新的表示方式。这个问题其实可以粗暴地解决,比如13W字符嘛,每个字符我都用32bit来保存,因为32bit最多可以保存2147483647个数字(21亿个数字),显然非常足够表示这些字符,以后的扩展应该也很难突破这个范围。但这就有个问题,明明我只有13万个字符,虽然用16个bit表示不了,但每个字符用32bit表示也太浪费了,无论是装载到内存里,还是通过网络传输,都有非常大的浪费问题。
于是这里出现了一个使用ASCII字符集时代不会出现的问题:即那些字符对应的数字怎么转化为二进制。为此,发展出一个专门干这个的工具,叫编码方式,如果说字符集解决的是一个字符对应哪个数字的问题,编码方法解决的就是这个数字如何在内存里表示的问题。
上面提到的那些字符集,比如GBK,BIG5,在发布时其实都考虑到了这个问题,他们在指定字符对应的数字是什么的同时,也指定了这个数字在二进制层面怎么表示。
但有些字符集并没有同时指定编码方式,比如说unicode,这就出现了一些与字符集相对独立的编码方式。将unicode码转化为二进制的编码方式有好几种,上面说到的用32bit表示这些数字的方式,也是一种编码方式,即UTF-32,但这个编码方式太浪费存储和传输空间了,其适用范围非常窄,大家都不怎么爱用。另外两种常用的unicode的编码方式是UTF-8和UTF-16。UTF-16也比较简单,就是用16个bit来表示一个数字,但16bit能表示的数字最多只有65536个,显然不能完整表示unicode的所有字符。于是区别于UTF-16和UTF-32这种用固定bit位来表示数字的方法,UTF-8采取了可变长的bit位数来表示字符集中的数字,这就同时兼顾了不浪费空间和可扩展两个方面。关于UTF-8到底怎么编码数字,可以自行搜索。
同时,用不定长编码方式还有个好处,即每个字符的定界是单独的,一个byte(8bit)是不是一个字符的开始,由这个byte开头的几个bit决定,这就提高了较高的容错性,假如说在网络传输中出现了bit位丢失,也就只有丢失的几个bit位的字符出现问题无法恢复,计算机很快可以从接下来的byte中定位下一个正确字符的开头。如果用定长编码方式,一旦出现了bit位的丢失,整篇文章接下来的所有字符解码都会失败。UTF-*系列编码方式都是用来编码unicode字符集的,事实上,他们的名字英文全称就是:Unicode Transformation Formats,即Unicode转换格式。
虽然说一般字符集都和特定的一种或几种编码方式绑定,但区分字符集和编码方式,仍然是解决字符问题的重要基础知识。在谈到编码方式的问题时,就出现了又一个重要的新概念,即:
- 代码单元,Code Unit
代码单元是指一个已编码的文本中具有最短bit组合的单元。对于UTF-8来说,代码单元就是8bit,而UTF-16的代码单元就是16bit。换句话说,UTF-8中一个字节最小是8bit,UTF-16最小是16bit。
将字符对应的数字转换为二进制这个过程称为编码,将二进制转化为数字再转化为字符的过程称为解码。
所以charAt(n)到底发生了什么
java的String类比较完善,总之比char上面直接套了个壳的cpp的std::string好用多了,一个重要的改进就在于考虑到了字符集和编码问题。
java的String采取了unicode字符集,UTF-16编码方式,每个java的char总是16bit,但并不是标准的UTF-16,当需要表示的字符超过了UTF-16的表示范围时,就会使用两个char来表示,如这个字符?,复制到IDEA里就用两个char来表示,显示成这个样子String emoji = "\uD83D\uDC66",其中emoji.length()值为2。对此,stackoverflow中有人解释地很清楚:
- A Java char takes always 16 bits.
- A Unicode character, when encoded as UTF-16, takes “almost always” (not always) 16 bits: that’s because there are more than 64K unicode characters. Hence, a Java char is NOT a Unicode character (though “almost always” is).
- “Almost always”, above, means the 64K first code points of Unicode, range 0x0000 to 0xFFF (BMP), which take 16 bits in the UTF-16 encoding.
- A non-BMP (“rare”) Unicode character is represented as two Java chars (surrogate representation). This applies also to the literal representation as a string: For example, the character U+20000 is written as “\uD840\uDC00”.
- Corolary: string.length() returns the number of java chars, not of Unicode chars. A string that has just one “rare” unicode character (eg U+20000) would return length() = 2 . Same consideration applies to any method that deals with char-sequences.
- Java has little intelligence for dealing with non-BMP unicode characters as a whole. There are some utility methods that treat characters as code-points, represented as ints eg: Character.isLetter(int ch). Those are the real fully-Unicode methods.
所以,虽然java的每个char都是16bit,但其实可以表示所有的unicode字符。
因此,当我们在java中使用charAt(n)时,就是返回在内存中的第n个char,一个char 16bit。换句话说,也就是一个代码单元
幸运的是,unicode中1234567890这些字符对应的数字全部小于65535,并且,java的编码方式保证了,如果一个码点需要两个代码单元表示,这两个代码单元都不会落在[1-9]之间,java中的的一个char可以搞定,所以我们总是能用charAt(n)来判断第几个字符,并且判断其是否落在0-9之间,即使字符中出现了需要两个代码单元的字符,charAt(n)返回的字符也不可能落在[1-9]之间,从而不影响判断。
但理解字符集和编码方式的意义在于,我们得知道0-9这十个代表数字的字符并不必然是一个char能表示的,特别是对于C语言这种需要直接操作底层byte的语言,更需要注意具体编码方式可能带来的影响,在java里,charAt(n)也并不是一定获得一个unicode字符(一个码点),而只是获得一个java中的16bit的char(一个代码单元)而已。
在java中,如果想获得一个字符的码点,可以用这个方法,并且记得用int去接,不要用char接返回结果:int codePoint = description.codePointAt(n);
一些扩展问题
字符集的问题总会经常困扰中文社区的程序员们,而且是一个牵扯甚广的问题,比如说在web通信中,假如要提供文件下载功能,比较传统的做法是获取response的输出流,往里面写文件:
@GetMapping("/downloadFileById")
public void downloadFileById(@RequestParam String filePath, HttpServletResponse response) {
File file = new File("/Users/mt/Desktop/" + filePath);
try (FileInputStream fis = new FileInputStream(file);
OutputStream os = response.getOutputStream()) {
// 对文件名首先进行编码
String encodedFileName = java.net.URLEncoder.encode(file.getName(), "UTF-8");
response.addHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");
response.addHeader("Content-Type", "application/octet-stream");
byte[] buffer = new byte[1024];
int i;
while ((i = fis.read(buffer)) != -1) {
os.write(buffer, 0, i);
}
os.flush();
} catch (IOException e) {
log.error("下载出错", e);
}
}
文件内容是直接读取字节流返回的,不会有问题,但是文件名就必须经过编码才能正确显示。
同样的问题出现在zip文件的解压过程中,zip对于文件的压缩是基于字节流的,不会有问题,但是文件名怎么保存却没有规范,于是windows默认的zip打包,将文件名用GBK编码了,如果拿到linux上解压缩就会出现乱码。但是,一般情况下你无法预知到底这个zip是GBK编码,还是UTF-8编码,这就导致你无法以兼容的方式处理它们,我们在业务中就遇到了这个问题,后来解决方法非常简单粗暴:
org.apache.commons.compress.archivers.zip.ZipFile zipFile;
File pushFile = new File("path/to/zip")
try {
zipFile = new ZipFile(pushFile, "GBK");
} catch (java.nio.charset.MalformedInputException e) {
zipFile = new ZipFile(pushFile, "UTF-8");
}
幸运的是,UTF-8编码的zip文件用GBK解码时会抛出异常,catch后再重新用UTF-8解码,算是解决了这个问题。
同样的,txt文件本身也没有指定编码,一般windows下新建的文件编码是GBK,linux下默认是UTF-8,为了能更好地解析不同编码的文件,我在java里引入了这样的一个包:
<dependency>
<groupId>com.googlecode.juniversalchardet</groupId>
<artifactId>juniversalchardet</artifactId>
<version>1.0.3</version>
</dependency>
public class EncodeGuesser {
private static final UniversalDetector detector = new UniversalDetector(s -> {
});
public static Charset guessCharset(byte[] buf) {
return guessCharset(buf, 0, buf.length);
}
public static Charset guessCharset(byte[] buf, int offset, int length) {
detector.handleData(buf, offset, length);
detector.dataEnd();
String encoding = detector.getDetectedCharset();
detector.reset();
if (encoding == null) {
throw new RuntimeException("为txt识别字符集出错");
}
return Charset.forName(encoding);
}
}
一个char转int怎么办
现在我们解决了怎么从String里取一个int的问题,那么接下来的问题就是,这个char如何转换为int。以前写C的时候我们经常这么写:
char a = '2';
int aInt = a - '0';
之所以能执行这样的减操作,是因为如上文所说,这些字符在计算机中都有唯一的一个数字代表,但是要确保'9'-'0'=9,则必须依赖于字符集里数字都是一个捱一个,而且0在前9在后。否则假如数字不挨在一起,那么’9’可能在字符集里被赋值20,’0’被赋值0,那相减肯定不是9,如果0在后,显然这个减法也不能成立。
幸运的是,迄今为止,所有的主流字符集里,0-9这些字符都是挨着的。
负数比正数多一个
这个是老生常谈,主要是因为整数在计算机中的存储使用了补码的方式,当最高位为1时,认为这个数是负数。因此,32位负数的最小值是0x80000000,而32位正数的最大值是0x7fffffff
虽然这是大家都知道的问题,但是真正在做String转int时,又经常会忽略。为了能比较优雅的处理这个问题,java的Integer.parse(String s)写的很tricky,有兴趣的可以看一眼。他是先把值转为负数,然后一直减,最后返回时再根据正数还是负数,再进行一次转换。
0 条评论