从一个String转Int说字符集与编码方式

于由astupidcoder发布

从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 条评论

发表回复

Avatar placeholder

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据。