【文档翻译】每个开发者都必须了解的关于Unicode和字符集的基本知识

发布时间 2023-11-17 21:23:55作者: ClickForWhat

本文档译自 joelonsoftware.com 的文章"The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)",作者 joel,原文参见此处


概述 - Overview

你是否在某一个平凡的日子,思考过那个神秘的 Content-Type 标签?你知道你应该把它放在 HTML 中,但却一直不知道它应该写成什么(以及为什么)?

你是否收到过远在国外的朋友的邮件,而邮件的标题是“???? ?????? ??? ????”?

我很沮丧地发现,有相当多的软件开发人员并没有真正完全跟上字符集、编码、Unicode 等神秘世界的发展速度。在许多年前,FogBUGZ 的一个测试者想知道它是否能处理收到的日文电子邮件。当我仔细观察我们用来解析 MIME 电子邮件的商业 ActiveX 控件时,我们发现它对字符集做了完全错误的事,所以我们不得不编写代码来拯救它所做的错误转换并使用正确方案。当我研究另一个商业库时,它同样有支离破碎的字符处理代码。我和那个库的开发者联系过,他认为他们对此无能为力。像许多程序员一样,他只希望能早日从混沌中解脱。

但是它不可能自己变好。当我发现流行的 web 开发工具 PHP 几乎完全无视字符编码问题,无忧无虑地使用 8 位字符,导致它几乎不可能开发出好的国际 web 应用程序时,我想,我 TM 受够了。

所以我要在这里宣布:如果你是一个在 2003 年工作的程序员,而且你还不知道字符、字符集、编码和 Unicode 的基础知识的话,如果被我抓住,我会狠狠地惩罚你,让你在潜艇里剥 6 个月的洋葱。我发誓我会的?。

以及还有一件事我得说:

这根本没那么难!

在本文中,我将向你详细介绍每个在职程序员都应该知道的事情。那些”文本字符 == ASCII == 字符就是 8 个比特” 的说法是相当不正确的,而且是无可救药的错误。如果你仍然以这种方式编程,你就不会比一个不相信细菌存在的医生好多少。所以在读完本文之前,不要急着写任何代码。

在开始之前,我应该提醒你,如果你是极少数了解文本国际化的人之一,你会发现我的整个讨论有点过于简化了。原因是我想提供一个最低的理解门槛,这样每个人都能理解发生了什么,并且可以依此编写能够处理任何语言文本的代码,而不仅仅只是英语(或英语的子集)。我还得提一句,字符处理只是创建国际化软件所需的一小部分,但我一次只能写一件事,所以今天只讨论字符集。


历史回顾 - A Historical Perspective

理解这些东西最简单的方法是看它的发展历程。

你可能以为我要开始大谈特谈那些老古董字符集,比如 EBCDIC。我不会的,EBCDIC 与你的生活无关。我们不需要回溯那么远的时间。

回到过去,当 Unix 被发明出来,K&R 在编写 C 语言时,一切都非常简单,EBCDIC 也即将退出市场。当时唯一重要的字符是老式的无重音英语字母,我们有一个叫做 ASCII 的编码表,可以用 32 到 127 之间的数字来表示这些英语字母和一些常用字符。比如空格符是 32,字母“A”是 65。这可以方便地用 7 个比特表示。那时候的大多数电脑都使用 8 比特字节,所以你不仅可以存储所有可能的 ASCII 字符,而且还可以节省一个比特,如果你比较狡猾,你可以用它来做些事情:WordStar 实际上会打开高位来表示此字符是单词中的最后一个字母。32 以下的字符没法打印出来,它们用于暗地里咒骂某人。

咳咳,开个玩笑。

它们被用于控制字符,比如 7 会让你的电脑发出哔哔声,12 会让当前的纸张飞出打印机,并输入新的纸张。

假设你是一个说英语的人,那么一切都很好。

由于一个字节能容纳 8 个比特,所以很多人就在想,“我可以用 128-255 的数字做点事情”。这本身没有问题。问题是,对 128-255 的数字应该放什么,每个人都有自己的想法。IBM-PC 做出了一些后来被称为 OEM 的东西,它为欧洲语言提供了一些重音字符和一堆线条绘制字符(水平线条,垂直线条等等),你可以用这些线条绘制字符在屏幕上画出漂亮的方块,如今你仍然可以在干洗店的 8088 电脑上看到这些玩意儿。事实上,当人们开始在美国以外的地方购买电脑时,人们就想出了各种不同的 OEM 字符集,它们都将后 128 个字符用于自己的目的。例如,在一些电脑上,字符代码 130 会显示为 é,但在以色列销售的电脑上,它是希伯来语字母 Gimel (ג)。这导致当美国人将 résumés 发送到以色列时,它们会以 rגsumגs 的形式到达。有时情况更棘手,俄语自身对于如何处理后 128 个字符甚至都有很多不同的想法,所以你甚至无法可靠地交换俄语文档。

最终,OEM 的混乱被 ANSI 标准所收服。在 ANSI 标准中,每个人都同意如何处理 128 以下的字符,这与 ASCII 基本相同,但是允许有很多不同的方法来处理 128 以上的字符,这取决于你住在哪里。这些不同的方案被称为 代码页code pages。例如,在以色列,DOS 使用名为 862 的代码页,而希腊用户使用 737。它们的 128 以下的字符是一样的,而之后的就不同了。MS-DOS 的国际版本有几十个这样的代码页,处理从英语到冰岛语的所有内容,甚至有几个少见的代码页,还可以处理世界语和加利西亚语!但是,在同一台计算机上同时显示希伯来语和希腊语是完全不可能的,除非你自己编写程序,用位图来显示所有内容。因为希伯来语和希腊语需要不同的代码页,对高位部分有不同的解释。

但在亚洲,更疯狂的事情正在上演。亚洲语言的字母有成千上万个,只有 8 个比特是远远不够的。这个问题通常用一个叫 DBCS 的系统解决,它是一种双字节字符集,其中一些字母可以存在一个字节里,而有些字母则需要两个字节来存。这中长短不一导致遍历一个字符串十分困难。于是 s++s-- 不再被鼓励使用,而是调用在 Windows 上诸如 AnsiNextAnsiPrev 之类的函数,它们知道该如何做。

但是,大多数人仍坚持一个字符就是一个字节,一个字符是 8 位。只要你从不把字符串从一台计算机移动到另一台计算机,或者不说英语之外的语言,它确实能正常工作。但当然,互联网一出现,把字符串从一台电脑转移到另一台电脑的行为就变得很常见了,整个混乱局面随之而来。幸运的是,Unicode 诞生了。


统一码 - Unicode

Unicode 是一次勇敢的尝试,它创建了一个单一的字符集,包括地球上所有存在的和虚构的书写系统(比如克林贡语)。一些人误认为它只是范围变大了,改用 16 个比特来简单表示一个字符(像 ASCII 那样的简单映射),因此总共可以表示 65536 个字符。这是完全错误的。不过这也是对 Unicode 最常见的误解,如果你也这么觉得,也别难过。

事实上,Unicode 用另一种思考方式来看待字符,你必须理解这种思考方式,否则就毫无意义。

到目前为止,我们假设一个字母直接映射到几个比特的组合:

A => 0100 0001

在 Unicode 中,一个字符映射到一个称为码点code point)的东西。请注意,这只是个抽象的概念,码点具体如何在内存或磁盘上表示则是另一回事。

Unicode 中,字母/字符,只是一个柏拉图式的抽象指代、一种超越现实世界的概念。

假设有一个字母/字符的集合:

ABaα

在这里,我们不能说它们具体都是什么,我们只能说它们彼此都不同。不过,我们会认为 A 和 AA 是相同的。这个观点的意思是,我们可以认为 Times New Roman 字体中的 A 与 Helvetica 字体中的 A 是相同的字符,但对于小写字母 a 来讲,则是不同的字符。

这个想法本身似乎没有太大争议。

但分辨不同字母的方式可能会有很大争议。比如,德语字母 ß 是一个真正的字母,还是只是 ss 的一种奇特写法?如果一个字母的形状在某个单词的末尾改变了,那它还是不是同一个字母?希伯来语说是,阿拉伯语说不是。无论如何,Unicode 联盟的聪明人在过去十年左右的时间里一直在尝试解决这个问题,伴随着大量的高浓度政治辩论。不过你不必担心,他们已经替你搞清楚了。

Unicode 联盟给每个语言系统的每个“柏拉图”字母分配了一个神奇的数字,比如这样:U+0639。这个神奇数字就是码点U+ 意思就是 “Unicode",后面的数字是十六进制的。阿拉伯语字母 ع 是 U+0639,英语字母 A 是 U+0041。你可以用 charmap 工具(Windows 2000/XP)或到 Unicode 的官网上去查询。

Unicode 可以定义的字母数量并没有真正的限制,码点本身对应的数字也没有上限,事实上,这数量已经超过了 65536,所以并不是每个 Unicode 字母都能映射到两个字节所能表示的数字上。

OK,假设有一个字符串:

Hello

在 Unicode 中,对应这五个码点:

U+0048 U+0065 U+006C U+006C U+006F

嗯,就只是一堆“码点”而已,真的。

到目前为止,我们还没有说明如何把码点存储在内存中或在电子邮件消息中表示它。


编码 - Encodings

自然,要在计算机世界里表示,我们就需要编码的概念。

对 Unicode 码点进行编码的最初想法,导致了那个普遍误解,”嘿,让我们把这些数字存储在两个字节里!“。所以 “Hello” 变成了:

00 48 00 65 00 6C 00 6C 00 6F

对吧?不过别着急!难道它就不能是:

48 00 65 00 6C 00 6C 00 6F 00

从技术上说,是的,我觉得它确实可以。早期的实现者希望能够以大端或小端两种模式存储 Unicode 码点,这样就产生了两种不同的存储 Unicode 的方法。因此,人们被迫提出了一个奇怪的约定,即在每个 Unicode 字符串的开头存储一个 U+FEFF;这也被称作 Unicode 字节顺序标记BOM。如果你的高位和低位字节互换,那它就是 U+FFFE,读取你的字符串的人就会知道他也必须互换。不过并非所有 Unicode 字符串的开头都有字节顺序标记。

在一段时间里,这方案似乎已经完美,但程序员们却仍在抱怨,”看看这些零!“。因为他们是美国人,他们看的是很少使用 U+00FF 以上码点的英文文本。如果他们是德克萨斯人,可能他们就不会介意这两倍的消耗。但是,那些加州人可不会同意这字符串消耗增加一倍的想法。而且无论如何,已经使用各种 ANSI 和 DBCS 字符集的文档,谁来转换它们呢?仅仅因为这个原因,大多数人就决定继续忽略 Unicode,这样情况变得更糟了。

于是就有了绝妙的 UTF-8 概念。UTF-8 是另一种使用 8 位字节存储 Unicode 码点字符串(那些神奇的 U+ 数字)的系统。在 UTF-8 中,从 0 到 127 的每个码点都存储在单个字节中。只有 128 及以上的码点使用多个字节(实际上最多 6 个字节)存储。

这还有个作用,就是英语文本在 UTF-8 中看起来与在 ASCII 中完全相同,因此美国人不会注意到任何不对的地方。比如,单词 "Hello",它的码点组合是 U+0048 U+0065 U+006C U+006C U+006F,将会以 48 65 6C 6C 6F 的形式存储,你看,这不就和 ASCII 一样嘛!而且,与 ANSI 标准、与所有这个星球上的 OEM 字符集显然也都是一样的。现在,如果你想大胆地使用重音字母、希腊字母或是克林贡语字母,那就必须使用多个字节来存储单个码点。(UTF-8 还有一个很好的性质,那就是使用单个 "0" 作为 null 结束符的旧字符串处理代码不会截断字符串)

到目前为止,我已经介绍了三种 Unicode 的编码方法。传统的两字节存储方法被称为 UCS-2 (因为它有两个字节)或 UTF-16 (因为它有 16 位),并且你需要弄清楚它是大端 UCS-2 还是小端 UCS-2。还有目前流行的新 UTF-8 标准,它有一个很好的特性,如果你有一段旧程序要处理英语文本,那么程序还是可以正常工作。

实际上还有很多的其他的编码 Unicode 的方法。有一种叫做 UTF-7 的东西。它很像 UTF-8,但总是保证高位为 0。所以如果你必须要通过某些认为 7 位已经足够的系统上传递 Unicode 时,谢天谢地,它还是能工作。还有一种叫 UTF-4,它把每个码点存在 4 个字节中,它有个很好的特性,即每个码点都可以存储在相同的字节数中(但是即使是德克萨斯人也不会如此大胆地浪费内存!)。

现在,我们考虑的是柏拉图式的理想字母,使用一种叫做 “Unicode 码点” 的东西来表示。事实上,这些码点也可以用任何老式的编码方案来编码。比如,你可以把 "Hello" 对应的Unicode 码点串(U+0048 U+0065 U+006C U+006C U+006F)用 ASCII、OEM Greek、Hebrew ANSI 或其它编码体系来编码。不过需要注意一点,有些字母会无法显示。如果你要表示的 Unicode 码点在你使用的编码体系中压根没有对应的字符,那么你可能会得到一个小问号 "?",或者得到一个 "�"。

传统的编码方法有数百种,它们只能存储其中一些码点,而把所有其他码点变成问号。一些流行的英语文本编码是 Windows-1252(Windows 9x中的西欧语言标准)和 ISO-8859-1,又名 Latin-1(也适用于任何西欧语言)。但是试图在这些编码中存储俄语或希伯来语字母,你会得到一堆问号。而 UTF-7、8、16 和 UTF-32 都具有能够正确存储任何码点的良好属性。


关于编码的最重要的事 - The Single Most Important Fact About Encodings

如果你完全忘记了上面说的内容,没关系,请记住一件事:如果不知道一个字符串所使用的编码,那这个字符串就毫无意义。你不能简单地认为一串纯文本就等同于 ASCII,事实上,根本就没有什么“纯文本”。

如果你在内存中、文件中或电子邮件消息中有一个字符串,你必须知道它的编码,否则就无法正确地解释它或将其显示给用户。

一些天真的程序员不明白一个简单的事实,即如果你不告诉我一个字符串是使用 UTF-8、ASCII 还是 ISO 8859-1(Latin-1)编码的,那我就无法正确显示它,甚至无法弄清楚字符串的结束位置。有超过一百种编码,当超过码点 127 时就无效了。

如何保存字符串使用的编码信息?有一些标准的方法。对于电子邮件消息,应当在标头中有一个字符串:

Content-Type: text/plain; charset="UTF-8"

对于网页而言,最初的想法是 Web 服务器会返回一个类似于 Content-Type 的 HTML 头和 Web 网页,注意,这里的字符编码并不是在 HTML 中指出,而是作为在 HTML 页面之前发送的响应报头中指出。

这带来了一些问题。假设你拥有一个大的 Web 服务器,拥有非常多的站点,每个站点都包括数以百计的 Web 页面,而写这些页面的人可能使用不同的语言,他们在自己计算机上的 FrontPage 等工具中看到页面能正常显示就提交到服务器上,显然,服务器是没有办法知道这些文件究竟使用的是何种编码,当然 Content-Type 头也没有办法发送了。

如果可以使用某种特殊标记,将 HTML 文件的 Content-Type 直接放在 HTML 文件本身中,那就方便多了。不过,在你知道 HTML 文件的编码之前,你怎么读它呢?幸运的是,几乎所有常用的编码都对 32 到 127 之间的字符做同样的事情,所以你总是可以在 HTML 页面上完成这一步:

<html>
<head>
<meta http-equiv="Conent-Type" content="text/html" charset="utf-8">

但是这个元标签必须是 <head> 节中的第一个,因为一旦 Web 浏览器看到这个标签,它就会停止解析页面,并在使用你指定的编码重新解释整个页面。

如果浏览器在 HTML 头或者 meta 标签中都找不到相关的 Content-Type 信息怎么办?以 Internet Explorer 为例:它会试图猜测出正确的编码,基于不同语言编码中典型文本出现的颇率。因为古老的 8 比特的代码页倾向于把它们的国家编码放置在 128-255 码字的范围内,而不同的人类语言字母系统中的字母使用频率对应的直方图会有不同,所以这个方法可以奏效。虽然很怪异,但对于那些老忘记写 Content-Type 的网页编写者而言,这个方法大多数情况下可以让他们的页面工作。直到有一天,他们写的页面不再满足文本的频率分布,Internet Explore 误认为这是朝鲜语,于是就当朝鲜语来显示了。这个页面的读者们立刻就遭殃了,一个保加利亚语写的页面却用朝鲜语来显示。于是读者不停地更改编码,最终试出了正确的那个,但前提是他知道可以这样做,事实上大多数人根本不会这样做。

在我的公司开发的一款 Web 页面管理软件 CityDesk 的最新版本中,我们决定像 Visual Basic、COM 和 Windows NT/2000/XP 所做的那样,一直使用 UCS-2 Unicode。在我们写的 C++ 代码中,我们把所有的 char 类型换成了 wchar_t,所有使用 str 函数的地方,换成了相应的 wcs 函数(如使用 wcscatwcslen 来替代 strcatstrlen)。如果想在 C 中创建一个 UCS-2 的字符串,只需在字符串前面加 L 即可:L"Hello"

当 CityDesk 发布网页时,它会将其转换为 UTF-8 编码,这种编码多年来一直得到网络浏览器的良好支持。这就是我的博客主页的所有 29 种语言版本 的编码方式,而且我还没有听到有人遇到任何问题。

这篇文章很长,我不可能涵盖字符编码和 Unicode 的所有内容,但我希望如果你已经阅读了这篇文章,那么你可以回到编程中,清除你代码里的细菌了。