1+1=10

扬长避短 vs 取长补短

C++中char、signed char、unsigned char、wchar_t、char16_t、char32_t、char8_t都是什么鬼?

C++20中引入了char8_t,目前看来,C++中字符类型全家应该都到齐了。

不妨,从头梳理一下,看看不发散的情况下,能写多少东西...

C++ 中字符类型

C++中的字符类型非常非常乱,根源在于C语言是上世纪70年代创建的,当时的char和现代语言比如Java、C#中的char完全不是一个概念。

先列个表格,看看全家福:

类型 引入C标准 引入C++标准 备注说明
char K&R C C++98 表示一个字节的字符
signed char K&R C C++98 有符号一个字节字符
unsigned char K&R C C++98 无符号一个字节字符
wchar_t C89 C++98 宽字符
char16_t C11 C++11 16位Unicode字符
char32_t C11 C++11 32位Unicode字符
char8_t C23 C++20 UTF-8编码字符

如果历史可以重来:

  • unsigned char 改为 byte
  • char16_t 改为 char
  • 其他去掉

只是,历史改不掉,C、C++也不可能像Python2到Python3那样变革,唯有接受现实,了解历史,规避各种坑

  • char、signed char、unsigned char 是三国演义
  • 不要思考char是有符号还是无符号
  • char的字面量在 C 与 C++ 下不一样
  • wchar_t跨平台很多坑:宽度不定,字符编码不定,是否独立类型不定
  • wchar_t在Windows下应用的非常非常普遍,但很多人用了而不自知
  • char16_tchar32_t 姗姗来迟,正视执行字符集
  • 你好char8_t,2011年你去哪儿了
  • 执行字符集
  • 源码字符集

char、signed char、unsigned char

由于历史原因,C语言从一开始就引入了这三个不同的类型。C++从一开始又从C语言继承了这三个类型。

首先这是三个不同的类型

它们很容易被误解成2个类型,因为signed int 和 int 是同一个类型。容易造成错误联想。

可以使用如下断言进行验证:

#include <iostream>

int main()
{
    static_assert( !std::is_same_v<char, unsigned char> );
    static_assert( !std::is_same_v<char, signed char> );
    return 0;
}

也可以直接输出类型看看:

#include <iostream>
#include <typeinfo>

int main()
{
    std::cout << typeid(char).name() << std::endl;
    std::cout << typeid(signed char).name() << std::endl;
    std::cout << typeid(unsigned char).name() << std::endl;

    return 0;
}

尽管在不同的编译器下,看到的具体内容不同。但是输出的三行信息总是不同的。

最直观的验证方式,使用函数重载:

#include <iostream>

void foo(char c)
{
    std::cout << "char: " << c << std::endl;
}

void foo(signed char c)
{
    std::cout << "signed char: " << c << std::endl;
}

void foo(unsigned char c)
{
    std::cout << "unsigned char: " << c << std::endl;
}

int main()
{
    foo('a'); // char
    foo(static_cast<signed char>('a')); // signed char
    foo(static_cast<unsigned char>('a')); // unsigned char

    return 0;
}

结果:

char: a
signed char: a
unsigned char: a

不要思考 char 有符号还是无符号

char 和 signed char、unsigned char 是不同的类型。使用char的时候,不要去思考它有没有符号。

但是,如果你特别想把它作为一个整数来用的话

#include <iostream>

int main()
{
    char a = -1;
    int aa = a;

    std::cout << aa << std::endl;
    return 0;
}

结果是什么??

  • -1
  • 255

两个皆有可能,取决于你的编译器选项

  • 对g++来说:
g++ debao.cpp
g++ -fsigned-char debao.cpp
g++ -funsigned-char debao.cpp
  • 对MSVC来说
cl debao.cpp
cl /J debao.cpp

如果你真想让char作为整数来用,最好在代码中转成unsigned char或signed char而后再用,不要使用此处列出的编译器选项。后者是uint8_tint8_t的替身,而char不是!

char字面量在C与C++行为不同

一个简单的例子,保存成 .c 还是 .cpp,结果不一样的:

#include <stdio.h>

int main()
{
    printf(sizeof 'a' == sizeof 1 ? "true" : "false");
    return 0;
}
  • g++ debao.ccl debao.c
true
  • g++ debao.cppcl debao.cpp
false

C++和C毕竟是两门语言,所以存在很多情况,尽管语法在两个语言下都对,但是运行效果不同。

wchar_t 的坑

Unicode1.0是1991年发布,wchar_t是89年进入C标准,98年进入C++标准的。

wchar_t与Unicode擦肩而过,如果当初能明确它对应UTF-16或者UCS2,压根就不会有后面一堆的事。

但是wchar_t从一开始就继承了C语言的优良传统:它的宽度是多少,编译器自己决定就可以,只要不比char窄就行了。别的!别的?啥都没说。

Unicode 4.0标准的5.2节是如何说的:

"The width of wchar_t is compiler-specific and can be as small as 8 bits. Consequently, programs that need to be portable across any C or C++ compiler should not use wchar_t for storing Unicode text. The wchar_t type is intended for storing compiler-defined wide characters, which may be Unicode characters in some compilers."

如果你的程序想要跨平台,就不应该使用wchar_t

GCC下的wchar_t

看个例子,该类型的宽度是多少?

#include <iostream>

int main()
{
    wchar_t a = L'a';

    std::cout << sizeof a << std::endl;
    return 0;
}

猜猜结果是多少?

  • 2
  • 4

结果都可以,却决于你的编译选项

g++ -fshort-wchar debao.cpp
g++ debao.cpp

不光宽度不定,更头大的是,在GCC下,还有-fwide-exec-charset这个选项(字符集也不确定,默认值都是4选1):

-fwide-exec-charset=charset

    Set the wide execution character set, used for wide string and character constants. The default is one of UTF-32BE, UTF-32LE, UTF-16BE, or UTF-16LE, whichever corresponds to the width of wchar_t and the big-endian or little-endian byte order being used for code generation. As with -fexec-charset, charset can be any encoding supported by the systems iconv library routine; however, you will have problems with encodings that do not fit exactly in wchar_t.

你就想吧,wchar_t 到底是个多么不可控的东西——它可以设置成你能想象到的任何字符集(只要iconv支持)。

MSVC下 wchar_t

你可能知道,MSVC下,或者说在Windows下,wchar_t用的非常非常多,因为Windows操作系统的API接口在广泛使用它。尽管很多人都是通过TCHAR,WCHAR或其他宏的形式在用它,没有直接手敲wchar_t

在MSVC下:

  • 好处,wchar_t 执行字符集是确定的——UTF-16
  • 麻烦:wchar_t是否是独立类型?

但不慌,看个例子:

#include <iostream>

int main()
{
    std::cout << std::boolalpha;
    std::cout << std::is_same_v<wchar_t, unsigned short> << std::endl;
    return 0;
}

结果是什么?

  • false
  • true

都有可能,取决于你的编译器选项

cl debao.cpp
cl /Zc:wchar_t- debao.cpp

如果你需要同时用两个预编译的第三方的C++库,接口都暴露了wchar_t,但是二者的编译选项不一致。可就有得玩了。

平心而论,微软将 wchar_t作为UTF-16来用,是很明智的。如果当时其他平台也跟进,弄成事实标准,就没有char16_t什么事情了。但是微软要确保wchar_t执行字符集是UTF-16,必须要明确源代码采用的什么编码存储的,编译器才能干活。为此,一个简单到极致的字符集问题,微软从2003年wchar_t进入C++标准,一直折腾到MSVC2015Update1,才折腾完。这十几年,Visual Studio的几乎每个版本,处理策略都不一样(真怀疑和IE一版本一变一样,他们就是故意的,故意的!)。

另外,注意:MSVC下的/Zc:wchar_t-和GCC下的-fshort-wchar完全不是同一个东西,不要类比,不要混淆,GCC下不管加什么选项,它都是独立类型。

char16_t、char32_t

wchar_t被玩废的情况下,不管C++在国际化上到底又多烂,C++11总算往前走了一大步。

都到了2011年,C++中连个现代意义上的字符都没有。由于char这个关键词一开始被C使用了,C++11只好引入了这两个长相怪异的兄弟。至此:

  • 源码字符集(仍然被C++标准丢到一边)
  • 执行字符集(提上日程)
#include <iostream>

int main() {
    const char *name = "1+1=10";
    const wchar_t *name2 = L"1+1=10";
    const char16_t *name3 = u"1+1=10";
    const char32_t *name4 = U"1+1=10";
    // const char *name5 = u8"1+1=10";

    rerurn 0;
}

这两个东西,特别是char16_t,很有用,但尴尬之处在于:我想写个小例子输出它,都不知道怎么写。

你倒是把 std::cout 这种基础设施弄好啊!

你好 char8_t

难以理解,char8_t 2020年才加入C++。

2011年引入u8"1+1=10"这种写法的时候,为什么不顺便把这个类型弄进来呢?

看个例子:

#include <iostream>

int main()
{
#if __cplusplus >= 202002L
    const char8_t *name = u8"1+1=10";
#elif  __cplusplus >= 201103L
    const char *name = u8"1+1=10";
#else
    const char *name = "1+1=10";
#endif

   std::cout << (char*)name << std::endl;
   return 0;
}

这是例子怎么写都无所谓,因为里面用的都是ASCII字符。

但是,不少人喜欢写下面的东西(国内的不少教材从这儿把大家带歪了):

const char *name = "你好";

一下子,档次就上去了(快看看我,非ASCII字符存进 char 里面啦):

  • 你用的源码字符集是什么东西?
  • 你用的执行字符集是什么东西?

啊?这两个概念都不知道,我们就敢这么写C++程序?就敢在C++中直接敲中文?甚至是就敢在注释中用中文?

const char8_t *name = u8"你好";
//const char *name = u8"你好";

这两个字符集的概念,一时半会是说不清楚了。

C++标准引入u8char8_t只是试图解决执行字符集问题。

执行字符集和源码字符集

要在C++中正确使用中文,必须要了解下面两个概念:

编码 备注
源码字符集(the source character set) 源码文件是使用何种编码保存的
执行字符集(the execution character set) 可执行程序内保存的是何种编码(程序执行时内存中字符串编码)
  • K&R C: 既没有规定源码字符集,也没有规定执行字符集
  • C++98:引入了wchar_t,问题依旧
  • C++11:引入char16_tchar32_t试图 规范化执行字符集UTF16、UTF32、以及UTF8
  • C++20:引入char8_t 试图规范化执行字符集 UTF8

例子

这个要求高么?

一个简单的C++程序,只是希望它能在简体中文Windows、正体中文Windows、英文版Windows、Linux、MAC OS...下的结果一致。

//main.cpp
int main()
{
    char mystr[] = "老老实实的学问,来不得半点马虎";
    return sizeof mystr;
}

可以试着反问自己两个问题

  • 这个源码文件是何种编码保存的?(有确定答案么?)
  • mystr中是什么内容?(有确定答案么?)

对C++来说,以上两点都不确定。

实际情况,实际上更有趣,单以MSVC2005为例(假定位于不同的国家的工程师都安装了MSVC2005,然后源码统一用不带BOM的本地字符集或UTF-8):

  • 情况1:德国工程师正常代码,原封不动拷贝到中国,编译报错!
  • 情况2:德国工程师正常代码,原封不动拷贝到中国,编译通过,输出乱码!
  • 情况3:德国工程师正常代码,原封不动拷贝到中国,编译通过,运行结果不同!
  • 情况4:在中国,代码注释中加入三个中文字符,编译报错!
  • 情况5:在中国,代码注释中加入三个中文字符,编译通过,运行逻辑错误
  • 情况6:...

如果两个人的MSVC的版本不一致,那么更火爆

如果一个人MSVC,一个GCC,矛盾简直不可调和(同情一下:一路走来的C++跨平台库Qt)...

GCC

在GCC下,这两个都可以使用你自己喜好的编码(如果不指定,默认都是UTF8)

-finput-charset=charset
-fexec-charset=charset

MSVC

在MSVC2015Update1之前,MSVC没有类似GCC前面的选项,需要灵魂拷问:

问题 方案
源码字符集如何解决? 有BOM么,有则按BOM解释,无则使用本地Locale字符集(随系统设置而变)
执行字符集如何解决? 使用本地Locale字符集(随系统设置而变)

MSVC2015Update1之后,引入类似gcc命令行选项,尽管只针对utf8的,但够用了。

这才两个编译器,看起来就这么复杂了。而C++编译器的数目远大于2.

要想跨平台,必须确保这两个字符集都是“确定”的,而能胜任该任务的字符集,似乎理想的也只能是...

UTF-8 执行字符集

标准通过引入新的类型来解决执行字符集的问题。很好!

但是新的类型我还是用不惯,因为第三方配套还不行!

我还是是不是要写下面这样的代码:

char mystr[] = "老老实实的学问,来不得半点马虎";

好处在于:我现在不用考虑傻白甜的MSVC2003了,不用头疼怎么处理油盐不进的MSVC2005了,不用纠结怎么给MSVC2008打补丁了,甚至不用考虑MSVC2010提供的如下方案了:

#if _MSC_VER >= 1600
#pragma execution_character_set("utf-8")
#endif

MSVC2015Update1开始,MSVC不光承诺二进制兼容性,而且还提供命令行选项了

/utf-8
/source-charset:utf-8
/execution-charset:utf-8

中日韩民众总算迎来一个好时代:C++执行字符集可以正大光明地用UTF-8,终于可以放心用本国字符而不用担心放到其他国家乱码了。

UTF-8 源码字符集

C++标准引入类型char8_tchar16_tchar32_t,明确规定了utf8、utf16和utf32这3种执行字符集。可是C++并没有规定源码字符集

const char8_t* mystr=u8"中文";

C++标准对编译器说,我不管这个文件的具体编码是什么,但你必须给我生成对应utf8编码的字节流。

编译器似乎有点傻了吧?不知道源文件的编码,我如何转换

于是:

GCC说:我认为你就是utf8编码,除非通过命令行通知我其他编码。

MSVC说:我就当你是本地locale的编码,除非你有BOM,或者通过命令行告诉我是utf8。

2015年之前,为了跨GCC和MSVC等平台,源码需要保存中带BOM的UTF8。2015年之后,完全不需要BOM了,命令行参数更好使。

突然之间,微软MSVC开始守规矩了,喜欢乱搞的IE也被干掉了,还真有点不适应。希望大家呼吁一下:什么时候微软能把系统API再搞一搞,以符合posix标准啊。

其他

C++中,char、signed char、unsigned char 三兄弟肩负了太多的职责。

除了没做好的字符的职责外,它们还承担寻址单位byte 和算数类型8位整数的职责:

std::byte

直到2017年,C++才开始剥离三兄弟的内存字节属性。

C++17 引入为名 byte 新类型。注意,这是个枚举class类型,不是整数:

enum class byte : unsigned char {};

用于标识内存中的字节类型,与char或整数不同,它不是字符类型,且不支持算术类型。只支持位操作符。

使用举例:

#include <iostream>
#include <cstddef>

int main() {
    std::byte b1 = std::byte(0x41); 
    std::byte b2 = std::byte(0b01010101); 
    std::byte b3{0x41};

    std::byte b4 = b1 | b2;
    std::cout << "Result of bitwise OR: " << static_cast<int>(b4) << std::endl;

    auto intValue = static_cast<int>(b3);
    std::cout << "Integer value of b3: " << intValue << std::endl;

    return 0;
}

int8_t、uint8_t

C++11 引入的类型别名。

只是类型别名,需要8位整数的时候,比用signed char、unsigned char显得好看。

注意,不管有符号,还是无符号,都不会用 char 的!

在不同的编译器下typedef是不同的。可以以 MSVC2019 为例,看一下:

typedef signed char        int8_t;
typedef unsigned char      uint8_t;

typedef signed char        int_least8_t;
typedef unsigned char      uint_least8_t;

typedef signed char        int_fast8_t;
typedef unsigned char      uint_fast8_t;

C++ 整数类型

其实,这一堆的字符类型,在C++中,都属于整数类型。

可以写个代码验证一下:

#include <iostream>
#include <type_traits>

#define TYPE_CHECK(type) check_integral<type>(#type)

template <typename T>
void check_integral(const char* typeName) {
    if (std::is_integral<T>::value) {
        std::cout << "integral type: " << typeName << std::endl;
    } else {
        std::cout << "non integral type: " << typeName  << std::endl;
    }
}

int main() {
    TYPE_CHECK(bool);
    TYPE_CHECK(char);
    TYPE_CHECK(signed char);
    TYPE_CHECK(unsigned char);
    TYPE_CHECK(short);
    TYPE_CHECK(unsigned short);
    TYPE_CHECK(int);
    TYPE_CHECK(unsigned int);
    TYPE_CHECK(long);
    TYPE_CHECK(unsigned long);
    TYPE_CHECK(long long);
    TYPE_CHECK(unsigned long long);

    TYPE_CHECK(wchar_t);
    TYPE_CHECK(char16_t);
    TYPE_CHECK(char32_t);
    TYPE_CHECK(char8_t);

    TYPE_CHECK(float);
    TYPE_CHECK(double);
    TYPE_CHECK(std::byte);

    return 0;
}

结果如下:

integral type: bool
integral type: char
integral type: signed char
integral type: unsigned char
integral type: short
integral type: unsigned short
integral type: int
integral type: unsigned int
integral type: long
integral type: unsigned long
integral type: long long
integral type: unsigned long long
integral type: wchar_t
integral type: char16_t
integral type: char32_t
integral type: char8_t
non integral type: float
non integral type: double
non integral type: std::byte

不同于Java等语言,对于各类型宽度,C++标准只保证:

1 == sizeof(char)  sizeof(short)  sizeof(int)  sizeof(long)  sizeof(long long)
1 == sizeof(char)  sizeof(wchar_t)

也就是说 char 和 long long 可以一样长,只要sizeof是 1就行。

C中的字符类型别名?

标题用了C++,所以前面主要在说C++,但考虑C++和C渊源关系,为了完整性,最后还是看一眼C。

不同于C++,截至到2024年,在C中:

  • 只有 charsigned charunsigned char 是关键字
  • wchar_tchar16_tchar32_tchar8_t都不是关键词,也不是原生类型
  • 它们只是类型别名!

伪代码如下:

typedef int wchar_t;
// typedef short unsigned int wchar_t;

typedef short unsigned int char16_t;
typedef unsigned int char32_t;
typedef unsigned char char8_t;

参考

  • https://gcc.gnu.org/onlinedocs/gcc-13.2.0/gcc/Preprocessor-Options.html

  • https://stackoverflow.com/questions/2324658/how-to-determine-the-version-of-the-c-standard-used-by-the-compiler

  • https://blog.csdn.net/dbzhang800/article/details/7540905

  • https://learn.microsoft.com/en-us/cpp/build/reference/utf-8-set-source-and-executable-character-sets-to-utf-8?view=msvc-170

  • Built-in types (C++) | Microsoft Learn

  • https://en.wikipedia.org/wiki/Wide_character

  • https://en.cppreference.com/w/cpp/language/type

  • https://en.cppreference.com/w/cpp/language/types

  • https://www.moria.us/articles/wchar-is-a-historical-accident/

C++ c++

Comments