C++ 标准
依赖于实现的行为:
- 很多功能依赖于实现,即每个实现版本都必须定义语法行为并记录行为规范。
- 例如,
char
类型的位数可能因实现而异,导致某些操作依赖于实现。 - 依赖于实现的功能与硬件系统密切相关。
不确定行为和未定义行为:
- 不确定行为意味着几种行为都有可能发生,但实现者没有指定具体是哪一种。
- 未定义行为是指具体实现无法为某一概念指定明确合理的行为,可能导致意想不到的结果。
- 例如,
new
运算符的返回结果和未同步的多线程赋值操作都是不确定的。
提高可移植性的做法:
- 明确哪些特性是依赖于实现的,并将这些敏感部分整理在一起,放在程序的明显位置。
- 将依赖于硬件的类型尺寸和定义放在头文件中。
- 使用
numeric_limits
和静态断言来检查特性是否依赖于实现。
实现
C++ 的一个具体实现可以有两种形式:宿主式(hosted)和独立式(freestanding)
宿主式实现是指 C++ 程序运行在一个完整的操作系统之上,例如 Windows、Linux 或 macOS。在这样的环境中,程序可以依赖宿主环境提供的标准库和系统服务。宿主式实现的特点包括:
- 标准库支持:宿主式实现提供了完整的 C++ 标准库支持,包括 STL(标准模板库)、I/O 流、文件操作等。
- 系统服务:程序可以调用操作系统提供的服务,如内存管理、进程控制、网络通信等。
独立式实现是指 C++ 程序运行在一个没有完整操作系统支持的环境中,例如嵌入式系统、微控制器或操作系统内核。在这样的环境中,程序不能依赖于宿主环境提供的服务,必须自给自足。独立式实现的特点包括:
- 最小化标准库:只提供 C++ 标准库的一个子集,通常是那些不依赖于宿主环境的功能
- 不依赖系统服务:程序不能调用操作系统提供的服务,必须自己实现所需的功能,如内存管理、文件系统等。
独立式实现所含的头文件 | |
---|---|
类型 | <cstddef> |
实现属性 | <cfloat> <limits> <climits> |
整数类型 | <cstdint> |
开始和终止 | <cstdlib> |
动态内存管理 | <new> |
类型信息 | <typeinfo> |
异常处理 | <exception> |
初始化器列表 | <initializer_list> |
其他运行时支持 | <cstdalign> <cstdarg> <cstdbool> |
类型特性 | <type_traits> |
原子 | <atomic> |
类型
-
C++ 中包含一套基本类型:
- 布尔值类型(
bool
) - 字符类型(如
char
和wchar_t
) - 整数类型(如
int
和long long
) - 浮点数类型(如
double
和long double
) - void 类型,表示类型信息缺失
- 布尔值类型(
-
基于基本类型的构造类型:
- 指针类型(如
int*
) - 数组类型(如
char[]
) - 引用类型(如
double&
和vector<int>&&
)
- 指针类型(如
-
用户自定义类型:
- 数据结构和类
- 枚举类型,表示特定值的集合(
enum
和enum class
)
其中:
- 布尔值、字符和整数统称为整型(integral type)
- 整型和浮点型统称为算术类型(arithmetic type)
- 枚举类型和类称为用户自定义类型(user-defined type)
- 基本类型、指针和引用统称为内置类型(built-in type)
基本类型
布尔值
#include <iostream>
int main() {
bool b1 = 7; // 因为7!=0,所以b1被赋值为true
//bool b2{ 7 }; // 错误: 发生了窄化转换
int i1 = true; // i1被赋值为1
int i2{ true }; // i2被赋值为1
bool a = true;
bool b = true;
bool x = a + b; // a+b的结果是2,因此x的最终取值是true
bool y = a || b; // a||b的结果是1, 因此y的值是true ("||"的含义是"或")
bool z = a - b; // a-b的结果是0,因此z的最终取值是false
int *p = nullptr;
bool b3 = p; // 窄化成true或false
bool b4{ p != nullptr }; // 显式地检查指针是否为非空
}
字符类型
-
char
:默认的字符类型,用于程序文本。char
是 C++ 实现所用的字符集,通常占 8 位。 -
signed char
:与char
类似,但是带有符号;换句话说,它可以存放负值。 -
unsigned char
:与char
类似,但是不带符号。 -
wchar_t
:用于存放 Unicode 等更大的字符集。wchar_t
的大小取决于实现,确保能够支持实现环境中用到的最大字符集(第 39 章)。 -
char16_t
:该类型用于存放 UTF-16 等 16 位字符集。 -
char32_t
:该类型用于存放 UTF-32 等 32 位字符集。
需要注意的是,char
类型到底带不带符号是依赖于实现的,这可能会带来一些意料之外的糟糕结果。例如:
char c = 255; // 255 的二进制表示是“全1形式”,对应的十六进制是 0xFF
int i = c;
i
的值是几?不幸的是,答案是未定义的。如果在运行环境中一个字节占 8 位,则答案依赖于 char
的“全 1 形式”在转换为 int
时是何含义。若机器的 char
是无符号的,则答案是 255;反之,若机器的 char
是带符号的,则答案是 -1。
一个可能的解决方案是放弃使用普通的 char
,而只使用特定的 char
类型。
虽然从本质上来说,char
的行为无非与 signed char
一致或者与 unsigned char
一致,
但这 3 个名字代表的类型的确各不相同。我们不能混用指向这 3 种字符类型的指针,例如:
char *ptr1;
signed char *ptr2;
unsigned char *ptr3;
// 不能混用这些指针
ptr1 = ptr2; // 错误
ptr2 = ptr3; // 错误
ptr3 = ptr1; // 错误
3 种 char
类型的变量可以相互赋值,但是把一个特别大的值赋给带符号的 char
是未定义的行为。例如:
void g(char c, signed char sc, unsigned char uc) {
c = 255; // 如果普通的char是带符号的且占8位,则该语句的行为依赖于具体实现
c = sc; // OK
c = uc; // 如果普通的char是带符号的且uc的值特别大,则该语句的行为依赖于具体实现
sc = uc; // 如果uc的值特别大,则该语句的行为依赖于具体实现
uc = sc; // OK:转换成无符号类型
sc = c; // 如果普通的char是无符号的且uc的值特别大,则该语句的行为依赖于具体实现
uc = c; // OK:转换成无符号类型
}
关于有符号转换成无符号:位模式保持不变,只改变解释方法
字符名字 | ASCII 名字 | C++ 名字 |
---|---|---|
换行 | NL(LF) | ‘\n’ |
横向制表 | HT | ‘\t’ |
纵向制表 | VT | ‘\v’ |
退格 | BS | ‘\b’ |
回车 | CR | ‘\r’ |
换页 | FF | ‘\f’ |
警告 | BEL | ‘\a’ |
反斜线 | \ | ‘\’ |
问号 | ? | ‘?’ |
单引号 | ‘ | ”’ |
双引号 | " | ‘"’ |
八进制数 | ooo | ‘\ooo’ |
十六进制数 | hh | ‘\xhh’ |
char v1[] = "a\xah\129"; // 6个字符: 'a' '\xa' 'h' '\12' '9' '\0'
char v2[] = "a\xah\127"; // 5个字符: 'a' "\xa" 'h' "\127" '\0'
char v3[] = "a\xad\127"; // 4个字符: 'a' '\xad' '\127' '\0'
char v4[] = "a\xad\0127"; // 5个字符: 'a' '\xad' '\012' '7' '\0'
整数类型
整数类型包括 short int
、int
、long int
和 long long int
。这些类型分别也可以简写为 short
、int
、long
和 long long
。
字面量
整数字面值常量分为 3 种:十进制、八进制和十六进制。
十进制 | 八进制(以 0 开头) | 十六进制(以 0x 或 0X 开头) |
---|---|---|
0 | 0 | 0x0 |
2 | 02 | 0x2 |
63 | 077 | 0x3f |
83 | 0123 | 0x63 |
后缀 U
用于显式指定 unsigned
字面值常量,后缀 L
用于显式指定 long
字面值常量。
通常情况下,整数字面值常量的类型由它的形式、取值和后缀共同决定:
-
如果它是十进制数且没有后缀,则它的类型是下面几种类型中能够表达它的值且尺寸最小的那个:
-
int
,long int
,long long int
-
-
如果它是八进制数或十六进制数且没有后缀,则它的类型是下面几种类型中能够表达它的值且尺寸最小的那个:
-
int
,unsigned int
,long int
,unsigned long int
,long long int
,unsigned long long int
-
-
如果它的后缀是
u
或U
,则它的类型是下面几种类型中能够表达它的值且尺寸最小的那个:-
unsigned int
,unsigned long int
,unsigned long long int
-
-
如果它是十进制数且后缀是
l
或L
,则它的类型是下面几种类型中能够表达它的值且尺寸最小的那个:-
long int
,long long int
-
-
如果它是八进制数或十六进制数且后缀是
l
或L
,则它的类型是下面几种类型中能够表达它的值且尺寸最小的那个:-
long int
,unsigned long int
,long long int
,unsigned long long int
-
-
如果它的后缀是
ul
,lu
,uL
,Lu
,UI
,IU
,UL
或LU
,则它的类型是下面几种类型中能够表达它的值且尺寸最小的那个:-
unsigned long int
,unsigned long long int
-
-
如果它是十进制数且后缀是
ll
或LL
,则它的类型是long long int
-
如果它是八进制数或十六进制数且后缀是
II
或LL
,则它的类型是下面几种类型中能够表达它的值且尺寸最小的那个:-
long long int
,unsigned long long int
-
-
如果它的后缀是
llu
,llU
,ull
,Ull
,LLu
,LLU
,uLL
或ULL
,则它的类型是-
unsigned long long int
-
总结,默认都会找到能容纳字面量的最小类型。
其中,十进制表示不会去找
unsigned
,而八进制或者十六进制会额外去找unsigned
浮点数类型
浮点数类型用于表示浮点数。浮点数是实数在有限内存空间上的一种近似表示。有 3 种浮点数类型:float
(单精度)、double
(双精度)和 long double
(扩展精度)。
字面量
字面量类型默认是 double
,使用后缀 f
或 F
可指定为 float
,使用后缀 l
或 L
可指定为 long double
。
前缀后缀总结
符号 | 前/中/后缀 | 含义 | 示例 |
---|---|---|---|
0 | 前缀 | 八进制 | 0776 |
0x/0X | 前缀 | 十六进制 | 0xff |
u/U | 后缀 | unsigned | 10U |
l/L | 后缀 | long | 20000L |
ll/LL | 后缀 | long long | 20000LL |
f/F | 后缀 | float | 10f |
e/E | 中缀 | 浮点数 | 10e-4 |
. | 中缀 | 浮点数 | 12.3 |
‘ | 前缀 | char | ‘c’ |
u’ | 前缀 | char16_t | u’c’ |
U’ | 前缀 | char32_t | U’c’ |
L’ | 前缀 | wchar_t | L’c’ |
" | 前缀 | 字符串 | "mess" |
R" | 前缀 | 原始字符串 | R"(\b)" |
u8R" | 前缀 | UTF-8 字符串 | u8"foo" |
uR" | 前缀 | UTF-16 字符串 | u"foo" |
UR" | 前缀 | UTF-32 字符串 | U"foo" |
LR" | 前缀 | wchar_t 字符串 | L"foo" |
C++ 允许用户为自定义类型定义新的后缀。
void
void
有两个作用:
- 一是作为函数的返回类型,用以说明函数不返回任何实际的值;
- 二是作为指针的基本类型部分,以表明指针所指对象的类型未知。
例如:
void x; // 错误:不存在void类型的对象
void& r; // 错误:不存在void类型的对象
void f(); // 函数f不返回任何实际的值
void* pv; // 指针所指的对象类型未知
类型尺寸
C++ 基本类型的某些方面是依赖于实现。如果需要编写可移植程序,则需要格外注意这一点。(例如,不同设备的 int 占用的空间可能不同)
使用 sizeof
关键字可以查看占用空间的情况。
对齐
内存对齐有助于硬件高效访问,可使用 alignof()
运算符(alignof
是关键字)查看内存对其情况
#include <iostream>
struct Foo
{
int i;
float f;
char c;
};
// 注意:如果需要,下面的 `alignas(alignof(long double))` 可以简化为 `alignas(long double)`。
struct alignas(alignof(long double)) Foo2
{
// 你的代码
};
struct Empty {};
struct alignas(64) Empty64 {};
int main()
{
std::cout << "Alignment of" "\n"
"- char : " << alignof(char) << "\n"
"- pointer : " << alignof(int*) << "\n"
"- class Foo : " << alignof(Foo) << "\n"
"- class Foo2 : " << alignof(Foo2) << "\n"
"- empty class : " << alignof(Empty) << "\n"
"- empty class\n"
" with alignas(64): " << alignof(Empty64) << "\n";
}
声明
在 C++ 程序中要想使用某个名字(标识符),必须先对其进行声明。换句话说,我们必须指定它的类型以便编译器知道这个名字对应的是何种实体。
大多数声明(declaration)同时也是定义(definition)。我们可以把定义看成是一种特殊的声明,它提供了在程序中使用该实体所需的一切信息。尤其是当实体需要内存空间来存储某些信息时,定义语句把所需的内存预留了出来。
在 C++ 程序中每个名字可以对应多个声明语句,但是只能有一个定义。在同一实体的所有声明中,实体的类型必须保持一致。
声明的结构
C++ 语法规定了声明语句的结构(iso.A)。这套语法最早从 C 的语法演化而来,历经四十多年的发展,变得相当复杂。在不做什么根本性简化的前提下,我们可以认为一条声明语句(依次)包含 5 个部分:
-
可选的前置修饰符(比如
static
、virtual
、extern
、constexpr
等) -
基本类型(比如
vector<double>
和const int
) -
可选的声明符,可包含一个名字(比如
p[7]
、n
和*(*)
)常用的声明运算符如下:
类型 运算符 说明 示例代码 前缀 * 指针 int* ptr;
前缀 *const 常量指针 int* const ptr = &value;
前缀 *volatile volatile 指针 volatile int* ptr;
前缀 & 左值引用 int& ref = value;
前缀 && 右值引用 int&& ref = std::move(value);
前缀 auto 自动类型推导 auto value = 42;
后缀 [] 数组 int arr[5];
后缀 () 函数 int func();
后缀 -> 从函数返回 auto foo2(int x) -> int; // 使用尾返回类型声明
需要注意的是,后缀运算符的绑定效果比前缀声明符更加紧密(优先级更高)
char c = 'h'; char* array_of_charptr[10]; // 这是一个含10个元素的数组,每个元素都是char*类型 array_of_charptr[0] = &c; char (*ptr_to_array_of_char)[10]; // 这是一个指向char[10]的指针 char c10[10]; ptr_to_array_of_char = &c10; // OK,&c10 返回的是一个 char(*)[10] //ptr_array_of_char = new char[10]; // 不OK,new char[10] 返回的是一个 char*
-
可选的后缀函数修饰符(比如
const
和noexcept
) -
可选的初始化器或函数体(比如
={7,5,3}
和{return x;}
)
声明多个名字
C++ 允许在同一条声明语句中声明多个名字,其中包含逗号隔开的多个声明符即可。
需要注意,在声明语句中,运算符只作用于紧邻的一个名字,对于后续的其他名字是无效的
int* p, y; // 准确的含义是 int* p; int y; 而非 int*y;
int x, * q; // int x; int* q;
int v[10], * pv; // int v[10]; int* pv;
这样的编程习惯会让程序看起来难懂,实际编程过程中最好避免。
名字
一个名字(标识符)包含若干字母(区分大小写)和数字。第一个字符必须是字母,其中,我们把下划线 _
也看成是字母。
此外,C++ 关键字(比如 new
和 int
)不能用作用户自定义实体的名字。
以下划线开头的非局部名字表示具体实现及运行时环境中的某些特殊功能,应用程序中不应该使用这样的名字。类似地,包含双下划线(__
)的名字和以下划线开头紧跟大写字母的名字(比如 _Foo
)都有特殊用途。
在一个范围较大的作用域中,我们应该使用相对较长且有明确含义的名字。然而,在范围较小的作用域中使
用一些长度较短但是约定俗成的名字也不失为一种好的选择。
作用域
-
局部作用域(
local scope
):- 函数或
lambda
表达式中声明的名字称为局部名字。 - 局部名字的作用域从声明处开始,到声明语句所在的块(用一对
{}
包围的代码片段)结束。
- 函数或
-
类作用域(
class scope
):- 如果某个类位于任意函数、类、枚举类或其他名字空间的外部,则定义在该类中的名字称为成员名字或类成员名字。
- 类成员名字的作用域从类声明的
{
开始,到类声明的}
结束。
-
名字空间作用域(
namespace scope
):- 如果某个名字空间位于任意函数、
lambda
表达式、类和枚举类或其他名字空间的外部,则定义在该名字空间中的名字为名字空间成员名字。 - 名字空间成员名字的作用域从声明语句开始,到名字空间结束为止。
- 名字空间名字能被其他翻译单元访问。
- 如果某个名字空间位于任意函数、
-
全局作用域(
global scope
):- 定义在任意函数、类、枚举类和名字空间之外的名字称为全局名字。
- 全局名字的作用域从声明处开始,到声明语句所在的文件末尾为止。
- 全局名字能被其他翻译单元访问。
-
语句作用域(
statement scope
):- 如果某个名字定义在
for
、while
、if
和switch
语句的()
部分,则该名字位于语句作用域中。 - 它的作用域范围从声明处开始,到语句结束为止。
- 语句作用域中的所有名字都是局部名字。
- 如果某个名字定义在
-
函数作用域(
function scope
):- 标签的作用域是从声明它开始到函数体结束。
下面是要给简单的例子:
#include <iostream>
// 全局变量x
int x = 100;
void f1() {
// 局部变量x
int x = 200;
{
// 嵌套作用域中的局部变量x遮蔽了外层的局部变量x
int x = 300;
std::cout << "In f1 inner scope, x = " << x << std::endl; // 输出:In f1 inner scope, x = 300
}
// 外层局部变量x的值恢复
std::cout << "In f1, x = " << x << std::endl; // 输出:In f1, x = 200
}
// 函数f2演示同一作用域内不同对象的使用
void f2() {
//int x = x; // 不OK,套娃
int y = x; // 使用全局变量x
std::cout << "In f2, y = " << y << std::endl; // 输出:In f2, y = 100
int x = 400;
y = x; // 使用新的局部变量x
std::cout << "In f2, y = " << y << std::endl; // 输出:In f2, y = 400
y = ::x; // 使用全局变量x
std::cout << "In f2, y = " << y << std::endl; // 输出:In f2, y = 100
}
int main() {
std::cout << "Global x = " << x << std::endl; // 输出:Global x = 100
f1();
f2();
return 0;
}
初始化
初始化器有四种可能的形式:
X a1 {v};
X a2 = {v};
X a3 = v;
X a4(v);
在这些形式中,只有第一种(C++11)不受任何限制,在所有场景中都能使用。
{}
的初始化称为列表初始化(list initialization),能够防止窄化转换。
如果使用 auto
关键字,则没必要采用列表初始化:
auto z1{99}; // z1的类型是 initializer_list<int>
auto z2 = 99; // z2的类型是 int
当我们构建某些类的对象时,可能有两种形式:一种是提供一组初始值;另一种是提供几个实参,这些实参不一定是实际存储的值,可能有别的含义。一个典型的例子是存放整数的 vector
:
#include <vector>
int main() {
std::vector<int> vec1 = {1, 2, 3}; // 提供一组初始值
std::vector<int> vec2(5, 10); // 提供几个实参,5表示大小,10表示初始值
return 0;
}
空初始化器列表 {}
指定使用默认值进行初始化,例如:
int x4{}; //x4被赋值为0
double d4{}; //d4被赋值为0.0
char* p{}; //p被赋值为nullptr
std::vector<int> v4{}; //v4被赋值为一个空向量
std::string s4{}; //s4被赋值为""
可以使用 {}
对内置类型的局部变量或者用 new
创建的内置类型的对象执行初始化:
#include <iostream>
int main() {
int x{}; // x的值变为0
char buf[1024]{}; // 对于任意i,buf[i]的值变为0
int* p{ new int{10} }; // *p的值变为10
char* q{ new char[1024] {} }; // 对于任意i,q[i]的值变为0
delete p;
delete[] q;
}
在没有指定初始化器的情况下(可以理解为没显式初始化):
-
全局变量、名字空间变量、局部
static
变量和static
成员(统称为静态对象(static object
))将会执行相应数据类型的列表{}
初始化。例如:int a; // 等同于“int a {};”,因此a的值变为0 double d; // 等同于“double d {};”,因此d的值变为0.0
-
对于局部变量和自由存储上的对象(有时也称为动态对象(
dynamic object
)或堆对象(heap object
))来说,除非类型有默认构造函数,否则不会执行默认初始化:#include <iostream> #include <vector> #include <string> int main() { int x; // x 没有定义好的值 char buf[1024]; // buf 没有定义好的值 int* p = new int; // *p 没有定义好的值 char* q = new char[1024]; // *q 没有定义好的值 std::string s; // 因为string的默认构造函数,所以s为空字符串 std::vector<char> v; // 因为vector的默认构造函数,所以v为空 std::string* ps = new std::string; // 因为string的默认构造函数,所以*ps为空字符串 // 释放动态分配的内存 delete p; delete[] q; delete ps; }
推断类型:auto
和 decltype()
auto
和 decltype()
的推断都是静态的,在编译阶段完成的。
#include <iostream>
int func() {
std::cout << "Hello, World!" << std::endl;
return 0;
}
int main() {
// 使用auto自动推断类型
auto a = 10; // a是int类型
auto b = 3.14; // b是double类型
auto c = 'A'; // c是char类型
// 使用decltype()获取表达式的类型
decltype(a) d = 20; // d是int类型
decltype(b) e = 2.718; // e是double类型
decltype(c) f = 'B'; // f是char类型
//decltype(func) g; // g是函数类型,没有参数,返回值为int。注意,这里相当于声明有一个g函数,但没有定义它的实现
decltype(func()) h; // h是int类型,因为func()返回int类型
}
左值和右值
当考虑对象的寻址、拷贝、移动等操作时,有两种属性非常关键。
- 有身份(Has identity):在程序中有对象的名字,或指向该对象的指针,或该对象的引用,这样我们就能判断两个对象是否相等或者对象的值是否发生了改变。
- 可移动(Is movable):能把对象的内容移动出来(比如,我们能把它的值移动到其他某处,剩下的对象处于合法但未指定的状态,与拷贝是有差别的)。
在上述两个属性的四种组合形式中,有三种需要用 C++ 语言规则精确地描述(既没有身份又不能移动的对象不重要)。我们“用 m
表示可移动”,且“用 i
表示有身份”,从而把表达式的分类表示成下图所示的形式:
从图中可知,一个经典的左值有身份但不能移动(因为我们可能会在移动后仍然使用它),而一个经典的右值是允许执行移出操作的对象。
void f(vector<string>& vs)
{
vector<string>& v2 = std::move(vs); // 移动vs到v2
// ...
}
此处,std::move(vs)
是一个特别值。它明显有身份(我们能像 vs
一样引用它),并且我们显式地给予了将其值移出的许可,方式是调用 std::move()
。
在实际编程过程中,考虑左值和右值就足够了。一条表达式要么是左值,要么是右值,不可能两者都是。
生命周期
对象的生命周期从构造函数完成开始,直到析构函数执行结束。对象可以根据生命周期被分为以下几类:
-
自动(automatic)对象:
- 在函数中声明,在其定义处被创建,超出作用域范围时被销毁。
- 通常分配在栈空间上。
- 每次函数调用时,会获取新的栈帧以存放这些对象。
-
静态(static)对象:
- 在全局作用域或名字空间作用域中声明的对象,以及在函数或类中声明的 static 成员。
- 只被创建并初始化一次,直到程序结束之前都存在。
- 地址在整个程序执行周期内唯一。
- 在多线程环境中,静态对象可能会引起问题,因为所有线程共享静态对象,需要加锁以避免数据竞争。
-
自由存储(free store)对象:
- 使用 new 和 delete 直接控制其生命周期的对象。
-
临时(temporary)对象:
-
例如计算的中间结果或用于存放 const 实参引用的值的对象。
-
生命周期由其用法决定:
-
如果绑定到引用上,则与引用的生命周期相同;
-
否则,与它所处的完整表达式( full expression)一致。
完整表达式:如果一个表达式不是其他表达式的子表达式,则称这个表达式为“完整表达式”。
以下是一个完整表达式:
int v3 = v1 + v2;
其中,
v1+v2
不是完整表达式,其还有更大的表达式int v3 = v1 + v2;
。
-
-
-
线程局部(thread-local)对象:
- 声明为
thread_local
的对象。 - 随着线程的创建而创建,随着线程的销毁而销毁。
- 声明为
类型别名
使用 using
关键字,给类型起个别名:
using Pchar = char*;
Pchar p = new char[10];
注意,类型别名前不允许加 unsigned
:
using Char = char;
Char c1 = 'a';// OK
unsigned Char c2 = 'b';// 不OK