WChars、编码、标准和可移植性

71
以下可能不符合SO问题的要求;如果超出范围,请随时告诉我离开。这里的问题基本上是:“我是否正确理解了C标准,这是正确的做法吗?”
我想请教关于C中字符处理的理解是否正确,以及更正和确认。首先,一个重要的观察结果是:
可移植性和串行化是正交概念。
可移植的东西是C、unsigned int、wchar_t等东西。可串行化的东西是uint32_t或UTF-8等东西。“可移植”意味着您可以重新编译相同的源代码,并在每个支持的平台上获得工作结果,但二进制表示可能完全不同(或甚至不存在,例如TCP-over-carrier pigeon)。另一方面,可串行化的东西始终具有相同的表示形式,例如我可以在Windows桌面、手机或牙刷上阅读的PNG文件。可移植的东西是类型安全的,可串行化的东西需要类型游戏。
当涉及C中的字符处理时,有两组与可移植性和串行化相关的事物:
- wchar_t、setlocale()、mbsrtowcs()/wcsrtombs():C标准没有关于“编码”的规定;实际上,它对任何文本或编码属性都是完全不可知的。它只是说“您的入口点是main(int, char**);您获得一个可以容纳您系统中所有字符的类型wchar_t;您获得函数以读取输入字符序列并将其转换为可处理的wstring,反之亦然。 - iconv()和UTF-8,16,32:一个在明确定义、确定、固定编码之间转换的函数/库。iconv处理的所有编码都是被普遍理解和认可的,只有一种例外。
C中可移植、不涉及编码属性的世界与决定性外部世界之间的桥梁是WCHAR-T和UTF之间的iconv转换。
因此,我应该始终在编码不可知的wstring中内部存储我的字符串,通过wcsrtombs()与CRT进行接口交互,并使用iconv进行串行化吗?从概念上讲:
                        my program
    <-- wcstombs ---  /==============\   --- iconv(UTF8, WCHAR_T) -->
CRT                   |   wchar_t[]  |                                <Disk>
    --- mbstowcs -->  \==============/   <-- iconv(WCHAR_T, UTF8) ---
                            |
                            +-- iconv(WCHAR_T, UCS-4) --+
                                                        |
       ... <--- (adv. Unicode malarkey) ----- libicu ---+

实际上,这意味着我将为我的程序入口编写两个样板包装器,例如对于C++:

// Portable wmain()-wrapper
#include <clocale>
#include <cwchar>
#include <string>
#include <vector>

std::vector<std::wstring> parse(int argc, char * argv[]); // use mbsrtowcs etc

int wmain(const std::vector<std::wstring> args); // user starts here

#if defined(_WIN32) || defined(WIN32)
#include <windows.h>
extern "C" int main()
{
  setlocale(LC_CTYPE, "");
  int argc;
  wchar_t * const * const argv = CommandLineToArgvW(GetCommandLineW(), &argc);
  return wmain(std::vector<std::wstring>(argv, argv + argc));
}
#else
extern "C" int main(int argc, char * argv[])
{
  setlocale(LC_CTYPE, "");
  return wmain(parse(argc, argv));
}
#endif
// Serialization utilities

#include <iconv.h>

typedef std::basic_string<uint16_t> U16String;
typedef std::basic_string<uint32_t> U32String;

U16String toUTF16(std::wstring s);
U32String toUTF32(std::wstring s);

/* ... */
这是否是使用纯标准C/C++编写习惯,可移植,通用且与UTF使用iconv定义良好的I / O接口编写程序核心的正确方式?(请注意,像Unicode规范化或变音替换之类的问题超出了范围;只有在您实际上想要Unicode(而不是其他任何编码系统)时,才是处理这些特定问题的时候,例如使用专用库如libicu。)
更新:
许多非常好的评论后,我想添加一些观察:
- 如果您的应用程序明确希望处理Unicode文本,则应使iconv转换成为核心组成部分,并在内部使用UCS-4的uint32_t/char32_t-字符串。 - Windows:虽然使用宽字符串通常没问题,但似乎与控制台(任何控制台)的交互受到限制,因为没有支持任何明智的多字节控制台编码,并且mbstowcs基本上无用(除了用于微不足道的加宽)。从Explorer-drop接收来自宽字符串参数,以及GetCommandLineW + CommandLineToArgvW一起工作(也许应该为Windows提供单独的包装器)。 - 文件系统:文件系统似乎没有任何编码概念,只需将任何以空字符结尾的字符串作为文件名即可。大多数系统使用字节字符串,但Windows / NTFS使用16位字符串。当发现存在哪些文件以及处理该数据(例如,不构成有效UTF16的char16_t序列(例如裸替代项)是有效的NTFS文件名)时,您必须小心。标准Cfopen无法打开所有NTFS文件,因为没有可能的转换将映射到所有可能的16位字符串。可能需要使用特定于Windows的_wfopen。作为推论,在一般情况下,“有多少个字符”包含给定文件名没有明确定义的概念,因为首先不存在“字符”的概念。买家自负。

3
尽管我认为如果wmain接受std::vector作为参数,它不应该是extern "C"。 (我认为不应该将C++类传递给具有C语言链接的函数。) - Nemo
2
“你会得到一个类型为wchar_t的变量,它可以容纳你系统中的所有字符”——不,情况比这更糟。在Windows中,wchar_t可能只能容纳代理对的一半。对于这些字符,你需要两个wchar_t对象才能包含整个字符。情况可能更糟。如果我没记错的话,一个令人讨厌但合法的实现可能会使wchar_t与unsigned char相同。” - Windows programmer
3
没错,代理人并不是一个字符,这正是为什么你不会得到一个能够容纳你系统所有字符的类型wchar_t。 - Windows programmer
2
如果定义了 __STDC_ISO_10646__,则 wchar_t 值是 Unicode 代码点。C1x 有 __STDC_UTF_16____STDC_UTF_32__ 分别用于 char16_tchar32_t,但 C++0x 似乎没有这两个宏。 - ninjalj
5
只用一个词:阅读 http://www.utf8everywhere.org,了解如何、为什么、发生了什么以及现在应该怎么做,还有其他人应该怎么做。 - Pavel Radzivilovsky
显示剩余17条评论
4个回答

25

这是使用纯标准C/C++编写习惯、可移植、通用、不受编码影响的程序核心的正确方法吗?

不是。如果你希望程序可以在Windows上运行,至少没有一种方法可以满足所有这些属性。在Windows上,你必须几乎无处不忽略C和C++标准,而且只能使用wchar_t(不一定是在内部,但所有与系统的接口都必须如此)。例如,如果你从以下内容开始:

int main(int argc, char** argv)

你已经失去了命令行参数的Unicode支持。你必须编写:

int wmain(int argc, wchar_t** argv)

相反,你可以使用GetCommandLineW函数等方式来代替,在C标准中没有指定这些方法。

更具体地说,

  • 在Windows上的任何支持Unicode的程序必须主动忽略像命令行参数、文件和控制台I/O或文件和目录操作这样的C和C++标准。这显然不是符合惯用法的做法。请使用Microsoft扩展或包装器,例如Boost.Filesystem或Qt。
  • 实现可移植性非常困难,特别是对于Unicode支持。您必须准备好一切您所知道的都可能是错误的。例如,您必须考虑您用来打开文件的文件名可能与实际使用的文件名不同,两个看似不同的文件名可能表示相同的文件。在创建了两个文件ab之后,您可能会得到一个单独的文件c,或者两个文件de,其文件名与您传递给操作系统的文件名不同。要么您需要一个外部包装库,要么就需要大量的#ifdef
  • 编码不可知性通常实际上并不起作用,特别是如果您希望具有可移植性。您必须知道在Windows上wchar_t是UTF-16代码单元,在Linux上char通常(但并不总是)是UTF-8代码单元。编码感知通常是更理想的目标:确保您始终知道使用哪种编码,或者使用一个抽象它们的包装库。

我认为我不得不得出结论:除非您愿意使用额外的库和系统特定的扩展,并且付出大量工作,否则在C或C++中构建可移植的Unicode应用程序是完全不可能的。不幸的是,大多数应用程序在比较简单的任务(例如“向控制台写入希腊字符”或“以正确的方式支持系统允许的任何文件名”)上已经失败,而这些任务只是迈向真正的Unicode支持的第一小步。


6
@Kerrek:不,wmain不是main的包装器,并且main不能处理Unicode。在使用Microsoft runtime的Windows控制台应用程序中,真正的入口点是_wmainCRTStartup,它通过GetCommandLineW获取命令行,解析它并调用wmain - Philipp
5
关于文件名。Windows 使用 UTF-16 作为文件名(以及其他所有内容)的编码方式,但您不能使用 fopen 访问它们。您必须使用非标准函数 _wfopen。如果您真的想要一个可移植的 C 或 C++ 程序,在 Windows 上支持 Unicode 是不可能的,我认为这在当今时代是难以接受的。因此最好忘记可移植性... - Philipp
5
@Kerrek:我认为C标准没有涉及文件名的内容。而且,如果您尝试打开任何名称在当前遗留编码(“ANSI代码页”)中无法表示的文件,则来自Microsoft C运行时的"fopen"函数将无法工作。基本上这意味着"fopen"是不能使用的。 - Philipp
2
是的,你可以使用 _wfopen 打开任何文件:这就是它的用途。但它是特定于 Windows 的。为了实现跨平台代码,你需要编写一个函数,在 Windows 上调用 _wfopen,在其他系统上调用 fopen - dan04
3
我不同意建议使用wchar_t工作。我认为char更适合支持Unicode。我观点的总结在utf8everywhere.org上。 - Pavel Radzivilovsky
显示剩余12条评论

9
我建议避免使用类型,因为它是平台相关的(根据你的定义不可“序列化”):在Windows上是UTF-16,在大多数类Unix系统上是UTF-32。相反,使用来自C++0x/C1x的和/或类型。(如果您没有新编译器,请将它们typedef为和)。
请定义函数以在UTF-8、UTF-16和UTF-32之间进行转换。
不要像Windows API一样编写每个字符串函数的重载窄/宽版本。选择一个首选编码用于内部使用,并坚持使用它。对于需要不同编码的内容,根据需要进行转换。

1
我认为我们对“平台相关”和“可移植”的理解不同。我不想在PC、Mac和Playstation之间交换我的RAM内容,我只想让程序在每个平台上编译和运行。理想情况下,我不想知道任何编码!我唯一需要担心编码的时候是在序列化/反序列化阶段,这是我使用iconv()进行接口的地方。在内部,我不想知道关于数据表示的任何信息。这样说是否有意义?就像基本的C语言格言,“重视值而非表象”。 - Kerrek SB
2
同样地,按照你的推理,“int”是平台相关的,因为在这里它是32位,在那里它是64位——是的,类型在不同的平台上可能具有不同的范围,但这并不意味着某些东西不可移植——它只是表现出不同的行为。例如,Windows XP不允许我使用非BMP Unicode字符,但Linux则可以。好吧,这就是本地化的结果。 - Kerrek SB
1
UTF-32 对于 Linux 来说并不像 UTF-16 对于 Windows 那样是“本地”的:所有的 POSIX API 函数(除了特别涉及宽字符处理的函数)都使用 char* 字符串。 - dan04
Windows API是另一回事。它的MultiByte*函数实际上告诉你它们生成Unicode。我只对标准C感兴趣。我相信<wchar.h>提供了所有标准函数的宽字符版本,例如wcstoul和wcscmp等等。没有本地的编码方式,因为语言标准不涉及i/o串行化格式。 - Kerrek SB

9
wchar_t存在的问题在于它处理不同编码的文本过于困难,应该尽量避免使用。如果您坚持使用“纯C”,可以使用所有w*函数,如wcscat等,但如果您想做更复杂的事情,那么就必须深入探索。

相比使用UTF编码之一,wchar_t会使以下任务更加困难:

  • 解析JavaScript:标识符可能包含某些BMP之外的字符(假设您关心此类正确性)。

  • HTML:如何将&#65536;转化为wchar_t字符串?

  • 文本编辑器:如何在wchar_t字符串中找到字形簇边界?

如果我知道一个字符串的编码方式,我可以直接查看其中的字符。如果我不知道编码方式,我就必须希望使用库函数来实现我想要做的事情。因此,wchar_t的可移植性有点无关紧要,因为我不认为它是一种特别有用的数据类型。

您的程序要求可能不同,wchar_t也可能适合您的需求。


好观点,我认为你真正抓住了问题所在,这完全取决于您想如何处理数据。如果明确的Unicode文本处理是核心部分,那么将转换为UTF32作为主要内部程序应该成为核心的一部分,而不是I/O(即输入是mbsrtowcs->iconv(WCHAR_T->UTF32);输出是相反的)。只需相应地调整我的ASCII艺术图表... - Kerrek SB
另一方面,如果文本字符串在您的程序中起到纯辅助作用(例如在最终得分屏幕上打印玩家名称),那么限制自己使用可用的系统字符是完全合理的。关于HTML:您需要知道页面的编码!如果是UTF32,则只需对U“\ 65536”执行iconv(UTF32-> WCHAR_T);它要么起作用,要么失败。您的文本和JS示例明确要求显式处理Unicode,请参见上文。(文本示例甚至可能需要使用高级unicode内容,例如libicu。) - Kerrek SB
此外,我同意一个抽象的“字符串”类型在不知道其编码的情况下的实用性可能相当有限。但是我肯定可以进行比较和匹配,甚至使用类似于L"foo"的文字常量,因此我认为也可能存在许多需要_某种_字符串处理的情况,但我从来不需要了解编码的细节 - 例如从stdin读取内容,为每个座位分配座位号并将结果输出到stdout。 - Kerrek SB
1
@Kerrek:虽然你不总是需要知道你正在使用哪种编码,但很难预测这是否适用于你的项目。选择特定的编码(UTF-8/16/32)相对较安全,除了一些特定于平台的API之外,我没有看到wchar_t有任何好处。如果考虑到一个可移植程序(根据规范),即使在转换后,也不能假设wchar_t可以存储任意Unicode字符串,那么情况就更糟了。 - Dietrich Epp
我想实际上这是有道理的。我猜测在理论上,你的环境可能使用了一种完全模糊不清的编码方式,你不知道也无法创建,因此需要使用wcstombs来创建可用的输出,并且需要通过内部的wchar_t字符串进行转换。但是现实情况是,当区域设置使用UTF8时,使用内部16位的wchar_t表示确实会不必要地限制你。那么我的真正问题是,如果不通过mbstowcs处理stdin数据,我应该如何处理呢? - Kerrek SB

6

鉴于iconv不是"纯标准的C/C++",我认为你没有满足自己的规格要求。

随着char32_tchar16_t的出现,有新的codecvt facets,只要你保持一致并选择一个字符类型+编码,我不认为你会错。

这些facets在22.5 [locale.stdcvt](来自n3242)中有描述。


我不明白这不符合你要求的部分:

namespace ns {

typedef char32_t char_t;
using std::u32string;

// or use user-defined literal
#define LIT u32

// Communicate with interface0, which wants utf-8

// This type doesn't need to be public at all; I just refactored it.
typedef std::wstring_convert<std::codecvt_utf8<char_T>, char_T> converter0;

inline std::string
to_interface0(string const& s)
{
    return converter0().to_bytes(s);
}

inline string
from_interface0(std::string const& s)
{
    return converter0().from_bytes(s);
}

// Communitate with interface1, which wants utf-16

// Doesn't have to be public either
typedef std::wstring_convert<std::codecvt_utf16<char_T>, char_T> converter1;

inline std::wstring
to_interface0(string const& s)
{
    return converter1().to_bytes(s);
}

inline string
from_interface0(std::wstring const& s)
{
    return converter1().from_bytes(s);
}

} // ns

然后你的代码可以毫不顾忌地使用ns::stringns::char_tLIT'A'LIT"Hello, World!",而不必知道底层表示方式。每当需要时,使用from_interfaceX(some_string)。它也不会影响全局语言环境或流。这些辅助程序可以非常聪明,例如codecvt_utf8可以处理“headers”,我认为这是类似BOM的复杂标准术语(同样适用于codecvt_utf16)。
实际上,我写上面的内容是为了尽可能简短,但你确实需要像这样的辅助程序:
template<typename... T>
inline ns::string
ns::from_interface0(T&&... t)
{
    return converter0().from_bytes(std::forward<T>(t)...);
}

这些函数让你可以访问每个[from|to]_bytes成员的3个重载版本,接受像const char*或范围等参数。


1
你所说的“提到”是什么意思?你可以通过typedef等方式进行重构(但除非使用宏,否则仍然必须接受给定文本)。正确的重载将被选中以适应与某些东西交互时需要的任何转换。如果您认为“编码不是编程概念”,那么为什么不选择UTF-32呢? - Luc Danton
所谓“提到”,是指如果我写 'a'L'a',我得到的是“字符'a'”,但我绝对没有权利假设它的实现方式(特别是它是否完全等于97)。我保证的是char可以容纳“a”,而wchar_t可以容纳“L'a'”。没有typedefs、没有选择、没有编码。只有字符“a”。 - Kerrek SB
1
@Kerrek 经过一番搜索,虽然可以从 (char, 窄编码) 转换为 (wchar_t, 宽编码),并且可以从任何 ([char, char16_t, char32_t], [utf-8, utf-16, utf-32]) 对中的任何一个转换到几乎任何其他对中,但标准没有提供一种从实现编码到 Unicode 编码的转换方式。我不会挽救这个答案,我建议使用 Philipp 的方法。 - Luc Danton
1
大家好 - 你们知道我们有一个出色的聊天功能,可以让你们继续这个迷人的讨论。 :) - Kev
你知道吗,我最终下载了一个libc++的副本并使wstring_convert工作,认为我应该更新这个问题,结果发现两年前你已经说了我想说的一切 :-S - Kerrek SB
显示剩余17条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接