已经过去了很长一段时间,感觉顶楼的有些地方值得重新考虑,这里重新总结一下。
这里将不会讨论任何与MFC有关的内容。因为MFC也需要熟练的Win32基础才能正确使用,现在来看,掌握Win32远比掌握MFC要重要得多。
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
C/C++在Visual C++中的字符串编码
源代码可以保存为ANSI格式、Unicode格式(含BOM)、Unicode big endian格式(含BOM)、UTF-8(含BOM)。
char[]字符串字面量"xxx"所使用的编码,是编译时系统当前ANSI代码页的编码。简体中文系统中就是GBK。
wchar_t[]字符串字面量L"xxx"所使用的编码,是UTF-16编码。
C/C++运行库使用的编码,是运行时系统当前ANSI代码页的编码。
虽然C/C++运行库也支持wchar_t[],但是仅推荐使用少数功能,因为C90Amendment1大多数功能是在Unicode还没有普及的时候发明的,因此大多数功能实现得不可靠。建议使用WinAPI实现需要wchar_t[]的功能。
WinAPI头文件windows.h
Windows系统调用是由Win32 API(WinAPI)进行的。Win32 API的最初版本是1993年的Windows NT,它设计了两套WinAPI:
- ANSI版本——以-A结尾,使用char[]作为字符串类型,使用ANSI字符集
- Unicode版本——以-W结尾,使用wchar_t[]作为字符串类型,使用Unicode字符集
后者(Unicode版本)是Windows NT原生的,前者是Windows NT通过自动转换字符串模拟的。
使用WinAPI需要引入头文件windows.h。
如果在引入windows.h之前定义了UNICODE宏(即选择“使用Unicode字符集”),则会将没有结尾的WinAPI映射到-W结尾的WinAPI上;如果没有定义这个宏(即选择“使用多字节字符集”或“未设置”),则会映射到-A结尾的WinAPI上。
为了表示通用的WinAPI字符类型,头文件定义了一个类型TCHAR,当定义了UNICODE宏,TCHAR它会映射到wchar_t,否则会映射到char;为了重定向字面量,定义了一个宏TEXT(x),当定义了UNICODE宏,TEXT("xxx")会映射到L"xxx",否则会映射到"xxx"。
引入tchar.h后,可以使用_TCHAR类型,以及更简短的_T("xxx")宏,定义_UNICODE宏以后,会映射到L"xxx",否则会映射到"xxx"。
为了windows.h与tchar.h的一致性,定义UNICODE宏的同时需要定义带下划线的_UNICODE宏,以同时使用宽字符映射,没有定义UNICODE宏的情况下同时定义_MBCS宏,以支持GBK等双字节字符串处理。
对字符串进行转换
**字节字符串与Unicode宽字符串之间转换,比较可靠的方法是使用WideCharToMultiByte和MultiByteToWideChar。**它的优点是稳定性强,并且支持ANSI、OEM、UTF-7、UTF-8等多种字符集,用对应的代码页表示。
- ANSI代码页:0(CP_ACP)(Windows代码页)
- OEM代码页:1(CP_OEMCP)(DOS代码页)
- UTF-7代码页:65000(CP_UTF7)
- UTF-8代码页:65001(CP_UTF8)
如果在某些地方因为预处理宏冲突等问题不能引入windows.h,可以使用如下函数定义:
__declspec(dllimport)
int __stdcall MultiByteToWideChar(
unsigned int codepage, unsigned long flags,
const char *srcstr, int srcsize,
wchar_t *dststr, int dstsize);
__declspec(dllimport)
int __stdcall WideCharToMultiByte(
unsigned int codepage, unsigned long flags,
const wchar_t *srcstr, int srcsize,
char *dststr, int dstsize,
const char *defchr, int *useddefchr);
示例代码:
// 日常科普:char和wchar_t转换
// wchar_t[]转char[]
wchar_t src_wstr[] = L"convert from wchar_t[] to char[]\r\n";
// 短字符串
char dst_str_short[1024] = "";
WideCharToMultiByte(CP_ACP, 0, src_wstr, -1, dst_str_short, 1024, NULL, NULL);
OutputDebugStringA(dst_str_short); // 使用char[]
// 长字符串
char *dst_str_long = NULL;
do // 多步可能失败的操作,用do-while(0)-break
{
int dst_str_cchsize = WideCharToMultiByte(CP_ACP, 0, src_wstr, -1, NULL, 0, NULL, NULL);
if (dst_str_cchsize <= 0) break; dst_str_long="(char" *)heapalloc(getprocessheap(), heap_zero_memory, dst_str_cchsize * sizeof (char)); if (dst_str_long="=" null) widechartomultibyte(cp_acp, 0, src_wstr, -1, dst_str_long, dst_str_cchsize, null, null); 下面是使用char[]的代码 outputdebugstringa(dst_str_long); }while(0); !="NULL)" do-while(0)-break之后只有清理代码 { heapfree(getprocessheap(), dst_str_long); } char[]转wchar_t[] char src_str[]="from char[] to wchar_t[]\r\n" ; 短字符串 wchar_t dst_wstr_short[1024]="L"";" multibytetowidechar(cp_acp, src_str, dst_wstr_short, 1024); outputdebugstringw(dst_wstr_short); 使用wchar_t[] 长字符串 *dst_wstr_long="NULL;" do 多步可能失败的操作,用do-while(0)-break int dst_wstr_cchsize="MultiByteToWideChar(CP_ACP," 0); (dst_wstr_cchsize <="0)" dst_wstr_long="(wchar_t" (wchar_t)); (dst_wstr_long="=" dst_wstr_long, dst_wstr_cchsize); 下面是使用wchar_t[]的代码 outputdebugstringw(dst_wstr_long); dst_wstr_long); code></=>
文本的处理
ANSI文本:建议使用系统的CharNextExA、CharPrevExA或_mbsinc、_mbsdec函数处理,而不要使用自己编写的算法。
UTF-8文本:建议直接转换为Unicode(宽字符)处理。如果要手工处理UTF-8文本,一定要在处理之前把char *指针转换为unsigned char *指针,不然由于char通常是有符号的,往往会导致编写的多字节算法无效。
宽字符文本:一般可以直接使用C运行库的wcs系列函数处理。如果需要获得单个码点的值,或者数码点数,需要使用IS_SURROGATE_PAIR判断UTF-16代理对。如果需要数复杂文字的组合字符数,需要使用CharNextW或CharPrevW函数,或者使用Uniscribe复杂文字分析技术。
引入tchar.h后,可以使用_tcslen、_tcscpy、_tcscat、_tcstol、_tcstoi64、_tcstod等函数,它们随_UNICODE宏和_MBCS宏是否定义而被映射到strxxx、wcsxxx、_mbsxxx函数。其中_tcstod(strtod、wcstod)函数是比较重要的,它可以将字符串转换为浮点数,这个功能很难用WinAPI实现。
引入strsafe.h后,可以使用StringCbXxxxx和StringCchXxxxx系列,前者要求缓冲区的字节长度,后者要求缓冲区的字符长度,它们实际上是包装了运行库函数的缓冲区安全化内联函数。同时,它们和WinAPI的规则是相同的,也使用UNICODE宏以及-W和-A结尾。其中StringCbPrintf和StringCchPrintf函数是比较重要的,它们可以将浮点数转换为字符串,这个功能很难用WinAPI实现。
命令参数
Visual C++支持宽字符入口点,通过它们可以获取获取宽字符命令参数。
int wmain(int argc, wchar_t *argv[]);
int __stdcall wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, wchar_t *szCmdLine, int nShowCmd);
引入tchar.h后,可以使用_tmain和_tWinMain宏表示入口点。
int _tmain(int argc, _TCHAR *argv[]);
int __stdcall _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, _TCHAR *szCmdLine, int nShowCmd);
使用这个入口点以后,只会初始化_wenviron全局变量,environ全局变量并没有被自动初始化,不过,只需调用一次getenv("")即可修复这个问题。
除此之外,使用GetCommandLineW可以在任何地方获取Unicode宽字符命令参数。GetCommandLineW返回的是固定地址,不需要释放。使用GetCommandLineA可以获取相应的多字节版本命令参数。
使用CommandLineToArgvW可以获取Unicode宽字符版本的argv。CommandLineToArgvW使用LocalAlloc分配了一个内存块,使用完需要使用LocalFree释放。它只能在Windows 2000以上操作系统使用。
#include <windows.h>
int __stdcall wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, wchar_t *szCmdLine, int nShowCmd)
{
// 主程序:获取Unicode宽字符版本的命令参数
int argcW = 0;
wchar_t **argvW = CommandLineToArgvW(GetCommandLineW(), &argcW);
if (argvW)
{
for (int i = 0; i < argcW; i++)
{
MessageBox(NULL, argvW[i], L"命令参数", MB_OK);
}
LocalFree(argvW);
}
return 0;
}
</windows.h>
文件I/O
一般来说,使用stdio.h就足够了,可使用fopen、_wfopen、_tfopen打开文件,fclose关闭文件。读写使用fread、fwrite或fgets、fputs等其它函数。
- 如果想要按照\n读取换行符,则应使用文本模式"r/w/a[+]"。
- 如果想要按照\r\n读取换行符,则应使用二进制模式"r/w/a[+]b"。
可以考虑使用open、_wopen、_topen、sopen、_wsopen、_tsopen打开文件,使用_read和_write读写文件。如果需要转换\n与\r\n换行符,使用_O_TEXT,否则使用_O_BINARY。
如果需要临时禁止转换换行符,使用_setmode(_fileno(stdxxx), _O_BINARY);。使用_setmode(_fileno(stdxxx), _O_TEXT);恢复转换换行符。在VC++2015之前并不支持freopen(NULL, "xxx");这种做法。
如果需要更高的性能和灵活性,可以使用CreateFile打开文件,使用CloseHandle关闭文件。使用ReadFile和WriteFile读写文件。
要注意的是,stdio.h读写文件,对于普通文件和管道或设备,具有一致的行为,而后两种方法,则对文件和管道或设备具有不一致的行为,更加贴近底层一些。
不要尝试使用", ccs=[UNICODE|UTF-16|UTF-8]"、O[W|U8|U16]TEXT,它是VC++2005时期微软的中二产物,手工转换虽然麻烦一点,但可靠性高。
标准I/O和控制台I/O
I/O编程实际上分为两个方向:面向一般设备(普通文件、管道、conin$/conout$等)、面向控制台(conin$/conout$)。
面向一般设备(普通文件、管道、conin$/conout$等)
直接使用stdio.h中的printf/scanf/fgets/puts/getchar/putchar等函数即可,并且为了和其它部分的C/C++程序统一编码,一般假定它们是ANSI。
不要和attrib/tree/more这些MS-DOS工具链统一编码。微软为了避免用户产生乱码的困惑,因此它们被设计为使用OEM代码页,是仿古程序(仿MS-DOS程序)。但是实际上仿古程序的编程是很复杂的,一般人不需要也没必要知道。
不建议直接使用_read/_write或ReadFile/WriteFile,因为普通文件、管道、conin$/conout$的I/O特性各不相同,如果要同时支持这些东西,相当于重新写了一遍stdio.h的函数。
面向控制台(conin$/conout$)
使用GetStdHandle(x)函数可以直接获取标准输入、标准输出、标准错误的Win32文件句柄,参数x可以是STD_INPUT_HANDLE、STD_OUTPUT_HANDLE、STD_ERROR_HANDLE。它们也可以通过(HANDLE)_get_osfhandle(_fileno(stdxxx))获取,如果你要与标准库同步的话。
除此之外,还可以使用CreateFile自行打开conin$/conout$。一定要使用如下标准形式。
HANDLE hconin = CreateFile(TEXT("conin$"), GENERIC_READ|GENERIC_WRITE,
FILE_SHARE_READ|FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL);
HANDLE hconout = CreateFile(TEXT("conout$"), GENERIC_READ|GENERIC_WRITE,
FILE_SHARE_READ|FILE_SHARE_WRITE, NULL, OPEN_EXISTING, 0, NULL);
可以使用ReadConsole/WriteConsole,其中ReadConsoleA/WriteConsoleA可以支持控制台当前代码页(可以通过GetConsoleCP()/GetConsoleOutputCP()获得),而ReadConsoleW/WriteConsoleW支持Unicode。可以支持。使用这些函数要注意几点:
- 请求字符数不要低于256字符,因为行缓冲最小是256字符(包含\r\n)。
- 请求字符数不要高于4096字符,C运行库的默认缓冲区大小,因为太大的请求字符数在WinXP/2003/Vista/7上容易RPC失败,4096字符已经能够满足大多数应用需要了。Win8+因为改成NtDeviceIoControlFile了,因此已经没有这个问题,但应该考虑兼容性问题。
- ReadConsoleA缓冲区尽量大一个字节,因为双字节代码页(如936)中这玩意的实现有bug,容易缓冲区溢出。
- UTF-8代码页65001虽然可以通过chcp切换过去,但是ReadConsoleA会出问题,因为它只是为了单字节代码页(如437)和双字节代码页(如936)设计的。
如何看待奇葩且难以改变的Win32编码体系?
为什么Windows的编码设定这么奇葩?ANSI字符集被淘汰了吗?ANSI字符集是为了Windows 95/98/Me而生的吗?在936之外,需要兼容932、949、950、1251、1252等其它ANSI代码页的系统吗?文本文件的编码情况如何?控制台的编码情况如何?文本文件的编码情况如何?
Windows并不是可以随意重编译的开源生态,而是二进制兼容的闭源生态。ANSI的WinAPI,编码无法随意升级,只能停留在上个世纪90年代的水平。Unicode的WinAPI,编码也不能改为UTF-8或UTF-32,只能使用UTF-16。
ANSI字符集并没有被淘汰,仍然是必需的。在编程中,我们一般认为ANSI和Unicode都够用了,使用Unicode更好,使用ANSI也是可以接受的。至于在用户名上使用emoji会发生什么困扰,是用户自己作死的事情。
在基于Windows NT的Windows操作系统中,ANSI字符集和Unicode字符集在Win32编程中地位是相同的。ANSI字符集是为了便于从(当年的)其它平台迁移而生的,远早于Windows 95/98/Me的出现,所以ANSI字符集不是为了Windows 95/98/Me而存在的。
如果程序要求GBK字符集,则没有必要兼容这些代码页,因为即使兼容了这些代码页,中文也会变成一堆问号。如果程序只要求ASCII字符集,可以考虑支持其它代码页。
在Windows中,文本文件分为ANSI、Unicode、Unicode big endian、UTF-8文本文件,其中后三者分别要以\xFF\xFE、\xFE\xFF、\xEF\xBB\xBF这些BOM序列开头,但是也经常遇到不包含BOM的UTF-8。通常的做法是,不带BOM的文件默认为ANSI,带有BOM的支持Unicode、UTF-8两种即可。可以放弃支持ANSI转而支持不带BOM的UTF-8,但是一定要同时支持带有BOM的UTF-8,不然肯定会对用户产生困扰。
控制台的编码情况比较复杂。控制台交互使用的是Unicode,但是控制台程序的文本流I/O(管道或重定向文件)并没有统一的编码标准,实际使用的可能是GetConsoleCP()和GetConsoleOutputCP()代码页的编码,但也有可能是固定为ANSI或OEM或437代码页的编码,甚至可能是UTF-8编码。对于控制台程序的文本流I/O,一般可以认为是代码页不确定的ASCII文本,应该提供选项让用户选择可读的编码。