C语言中的类型限定符、volatile、restrict以及_Atomic限定符
目录
1.2volatile限定符
volatile在英语中的意思是“不稳定的”,“易变的”,“易挥发的”。C语言用volatile限定符修饰一个对象时,指明该对象的值可能会被异步修改,这暗示了编译器不要对该对象做寄存器暂存优化,在读写它的时候总需要显式地从它的存储器地址中获取值。一般而言,C语言实现会将一个函数中多次出现的同一对象的值尽可能地存放在寄存器中,如果该对象的内容可以存放在寄存器中(不超过一个寄存器所能容纳的字节数),且寄存器数量足够。竟,寄存器访问比读写内存要快得多。
volatile一般用于多个线程所共享的资源,包括用于数据同步的锁对象。另外,嵌入式系统也会将MMR(存储器映射的寄存器)地址类型定义为指向volatile的指针类型。volatile修饰对象时所摆放的位置与它所起的修饰效果同const一样,这里不再赘述。此外,volatile可以与const一同使用,尽管这么做的场合不多,不过对于MMR的访问来说倒也不错——比如:intdata=*(const volatile int*)0xff800000UL;表示从0xff800000地址所映射的外设寄存器中获取int类型的相关数据。这里使用volatile限定符表示在每次出现*(const volatile int*)0xff800000UL时,都要显式地读取该地址中的内容,而不是在第一次读取之后就默认将该值存放在CPU的寄存器中,然后后续的读取都直接从该寄存器中获取数据
下面,我们将通过代码清单1-6来描述volatile限定符的使用以及效果。
代码清单1-6 volatile的使用及效果
#include <stdio.h>
#include <pthread.h>
// 这里定义了一个Fun函数,其形参a的类型为:int * const volatile
static void Fun(int a[static volatile const 2])
{
printf("a[0] = %d, a[1] = %d\n", a[0], a[1]);
}
// 这里先声明一个普通的int类型静态对象
static int normalInt;
// 这里声明了一个volatile的int类型静态对象
static volatile int volatileInt;
// 这个函数用于用户线程执行例程
static void* ThreadProc(void *param)
{
// 这里的考察很简单,做一个1000000次循环,
// 每次分别将normalInt与volatileInt递增,
// 最后看主线程中这两个值的变化
for(int i = 0; i < 1000000; i++)
{
normalInt++;
volatileInt++;
}
return NULL;
}
int main(int argc, const char* argv[])
{
// 在主线程中,一开始将normalInt与volatileInt初始化为0
normalInt = 0;
volatileInt = 0;
pthread_t threadID;
// 我们用pthread API创建一个用户线程,
// 该线程中normalInt与volatileInt在不断变化
pthread_create(&threadID, NULL, &ThreadProc, NULL);
// 我们这里就循环10000次,明显少于用户线程的1000000次,
// 这样,用户线程在对这两个考察对象的最终修改不会做综合(synthesize)
while(volatileInt < 10000);
// 我们在Release模式下能观察到,normalInt对象的值始终为0;
// 而volatileInt则得到了当前修改后的值
printf("normalInt = %d\n", normalInt);
printf("volatileInt = %d\n", volatileInt);
// 这里声明一个指针对象p,(*p)的类型为volatile int
volatile int *p = &volatileInt;
// 这里声明了一个指针对象q,它自身是volatile的。
// (*q)的类型则是int,不是volatile的
int * volatile q = &normalInt;
Fun((int[]){ *p, *q });
}
各位在运行代码清单1-6中的代码示例时必须注意,一定要将当前编译环境设置为Release模式(一般开发环境默认的设置是Debug模式),只有这样,编译器才会对代码做出优化,我们才能看到效果。而如果用命令行编译的话,我们可以直接用-O2命令选项,并且不添加-g命令选项即可。另外,在Linux环境下,我们需要使用--pthread连接命令选项来连接pthread的安全实现运行时库。而在macOS环境下默认已经把底层的库都连接好了,无需手工设置。
1.3restrict限定符
restrict限定符是从C99标准开始引入的。它的用法与之前的const和volatile有所不同,它只能用于修饰一个指针类型的对象,而不能用于修饰一个普通对象。通过受restrict限定的一个指针所访问的一个对象与该指针具有一种特殊的关联性。这种关联性要求,对该对象的所有访问都要直接或间接使用那个特定指针的值,而不受其他指针的干涉。使用restrict限定符可暗示编译器对通过指针访问的数据进行优化,比如说我们可以直接将受restrict限定的指针所读取到的值存放在寄存器中,后续再次出现对该指针的访问时可直接拿寄存器中的数据,而不需要做真正的访存操作。下面我们通过代码清单12-7举一个简单的示例,这样大家就能有一定的感性认识了。
下面我们通过代码清单1-7举一个简单的示例,这样大家就能有一定的感性认识了。
代码清单1-7 初窥restrict限定符
#include <stdio.h>
#include <stdint.h>
int main(int argc, const char* argv[])
{
// 这里声明一个数组,它含有8个uint8_t类型的元素
uint8_t bytes[] = { 0x10, 0x20, 0x30, 0x40,
0x50, 0x60, 0x70, 0x80 };
// 声明一个指向int32_t类型的指针对象p,它直接指向bytes数组的首地址
int32_t *p = (int32_t*)bytes;
// *p的初始值为0x40302010
printf("First, *p = 0x%.8X\n", *p);
// 声明一个指向int32_t类型的指针对象q,它直接指向bytes[2]位置元素的地址
int32_t *q = (int32_t*)&bytes[2];
// *q的初始值为0x60504030
printf("First, *q = 0x%.8X\n", *q);
*q += 16;
// 输出:*p = 0x40402010
printf("Now, *p = 0x%.8X\n", *p);
}
各位注意,代码清单1-7中的示例代码必须在x86处理器或ARMv7或更高版本的处理器中执行。各位可以看到,代码清单1-7中,指针对象p与指针对象q之间是有叠加部分的,即bytes[2]与bytes[3]部分,这两个元素所在的地址分别作为p所指对象的高2字节以及q所指对象的低2字节。这意味着无论是指针对象p还是指针对象q,它们都不能作为一个指向独立对象的指针,也就是说,(*p)与(*q)所表示的对象不是相互独立的,而是有叠交(aliasing)的。在这种情况下,指针p与指针q都不能用restrict限定符去修饰。
很显然,如果我们这里允许对(*p)与(*q)的访问做寄存器暂存优化,那么当对(*p)做修改时,(*q)的低2字节也被改变,而寄存器中的内容却得不到更新;同样,如果对(*q)做修改,那么(*p)的高2字节的值也被修改,而其寄存器中的内容也得不到更新,这会导致不可预期的结果。这也是为何无法对存储空间存在叠交的两个指针对象做restrict限定的原因。
对于一个受restrict限定符限定的指针对象,它所指向的存储空间应该在当前执行环境下是唯一的,没有其他指针与它指向同一个存储空间,并且也不存在任何与其他指针所指向的存储空间有重叠的情况。代码清单1-8进一步描述了restrict限定符的使用。
代码清单1-8 restrict限定符的进一步使用
#include <stdio.h>
#include <stdint.h>
// 这里参数a的类型为:int * const volatile restrict
static void Fun(int a[static const volatile restrict 2])
{
printf("The result is: %d\n", a[0] + a[1]);
}
/**
* 下面我们自制一条利用restrict限定符的存储数据拷贝函数
* @param pDst 指向目的存储空间
* @param pSrc 指向源存储空间
* @param count 指定要拷贝多少个int32_t的数据元素
*/
void MyfastMemCpy(int32_t* restrict pDst, const int32_t* restrict pSrc,
size_t count)
{
// 如果元素个数为偶数,我们直接2个元素2个元素进行拷贝,以提升效率
if((count & 1) == 0)
{
const size_t nLoop = count / 2;
uint64_t* restrict p = (uint64_t*)pDst;
const uint64_t* restrict q = (const uint64_t*)pSrc;
for(size_t i = 0; i < nLoop; i++)
p[i] = q[i];
}
else
{
for(size_t i = 0; i < count; i++)
pDst[i] = pSrc[i];
}
}
int main(int argc, const char* argv[])
{
// 声明了两个int类型对象a和b
int a = 10, b = 20;
// 声明了一个指向int类型的受restrict限定的指针对象p,
// 用对象a的地址对它初始化
int* restrict p = &a;
// 声明了一个指向int类型的受restrict限定的指针对象q,
// 用对象b的地址对它初始化
int* restrict q = &b;
// 以下这条语句是非法的!两个restrict限定的指针不能指向同一个存储空间。
// 尽管编译器对此不会有任何警告,但可能会引发未定义的结果
p = q;
Fun((int[]){*p, *q});
// 以下这条语句是非法的,restrict不能用于修饰非指针类型
restrict int x = 0;
// 以下这条语句是非法的,(*t)类型为int,不是指针类型
restrict int *t = NULL;
// 下面定义了两个数组,均含有1024个元素
int32_t dst[1024] = { 0 };
int32_t src[1024];
// 对src数组元素做初始化
for(int i = 0; i < 1024; i++)
src[i] = i;
// 调用我们自制的高效存储器拷贝函数
MyfastMemCpy(dst, src, 1024);
// 最后我们验证一下结果
for(int i = 0; i < 1024; i++)
{
if(src[i] != dst[i])
{
puts("Result not equal!");
return -1;
}
}
puts("Result equal!");
}
从代码清单1-8中可知,对restrict限定的指针的使用有其随意性,我们在写代码的时候如果无意间破坏了restrict的要求,编译器也无法识别,因为要实现这种识别对编译器来说开销会比较大。所以restrict关键字一般用于函数形参,提示函数使用者所传入的对象地址要确保其唯一性。我们在满足restrict要求的时候,我们可以提供更高效的运行时库,比如像代码清单12-8中的MyFastMemCpy函数。当我们确保pDst与pSrc这两个指针所指向的存储空间相互独立且不叠交时,我们可以采取各种优化措施,比如可以多个元素多个元素一起拷贝,只要CPU有这种能力。但是,倘若我们传入的指针所指向的存储空间不能满足唯一性,或者说当中有叠交,那就别说多个元素一起拷贝了,即便连指针所指向对象类型的粒度(这里是int32_t类型)进行拷贝都无法确保数据正确性(比如代码清单12-7所呈现的情况),只能一个字节一个字节进行拷贝。
1.4 _Atomic限定符
_Atomic限定符是在最新的C11标准中所引入的。所以它限定类型的方式比起const、volatile以及restrict有所不同,它直接用_Atomic(类型名)这种方式作为原子类型说明符。为何_Atomic能使用这种形式作为类型限定符呢?因为_Atomic一般修饰的是非指针对象类型,所以不牵涉限定指向数组的指针以及指向函数的指针这些比较特殊的类型表达形式。
如果将一个对象声明为原子类型,那么说明该对象是原子的,这也称为“原子对象”。原子对象的访问与非原子的有所不同,对一个原子对象的读和写都是不可被打断的,此外有很多针对原子对象的修改操作(比如加减算术计算以及各种逻辑计算等原子操作),这些原子操作也是不可被打断的。一个操作不可被打断意味着在执行整个操作过程中,即便有一个硬件中断信号过来,该中断信号也不能立即触发处理器的中断执行例程,处理器必须执行完整条原子操作之后才可进入中断执行例程。对于中断控制器而言往往会有“未决”(Pending)这个状态,说明当前中断尚未被处理。我们在使用原子操作的时候不用担心当前的执行线程会被切换,因为中断处理都不会发生。原子对象往往用于多核多线程并行计算中对多个线程共享变量的计算。
原子操作的另一大特点是,对于来自多个处理器核心对同一个存储空间的访问,存储器控制器会去仲裁当前哪个原子操作先进行访存操作,哪个后进行,这些访存操作都会被串行化,所以这对于多核多线程并行计算的数据同步而言是必需的处理器特征。我们无法通过简单的开关中断去控制各个核心同时执行不同线程的行为与状态,所以在多核心多线程并行计算的环境下,原子操作是唯一的数据同步手段。另外在此环境下,像互斥体(mutex)、信号量(semaphore)等同步原语的实现也都基于原子操作。
我们在C11标准下的C语言中使用原子操作时,应当包含<stdatomic.h>标准库头文件,该头文件中已经预定义了一些当前主流处理器所能支持的原子对象类型,此外还有相应的原子操作函数。我们在实际使用原子类型时应当避免直接使用_Atomic(类型名)这种形式,而是直接用<stdatomic.h>头文件中已经定义好的原子类型。当前C11标准中所罗列的能够支持原子对象类型的基本类型均为整数类型,也就是说除整数类型外的其他类型都无法作为原子对象类型(包括浮点类型)。我们常用的原子对象类型有:atomic_bool、atomic_char、atomic_schar、atomic_uchar、atomic_ushort、atomic_short、atomic_int、atomic_uint、atomic_long、atomic_ulong、atomic_char16_tatomic_char32_t、atomic_wchar_t、atomic_intptr_t、atomic_uintptr_t、atomic_size_t、atomic_ptrdiff_t等。像这里面atomic_int类型就被定义为_Atomic(int),而像atomic_size_t类型就被定义为_Atomic(size_t)。
此外,原子对象的初始化与普通对象也有所不同,在<stdatomic.h>头文件中定义了两个接口,分别用于对全局原子对象与函数内局部原子对象进行初始化。另外,对原子对象的读写也不应该直接用=赋值操作符,而是需要通过使用atomic_load函数进行读,atomic_store函数进行写。代码清单1-9将先简单介绍原子对象的一些基本操作。
代码清单1-9 原子对象的初步使用
#include <stdio.h>
#include <stdatomic.h>
// 这里声明了一个int类型的静态原子对象sIntAtom
// 我们通过ATOMIC_VAR_INIT宏函数对其初始化为100
static atomic_int sIntAtom = ATOMIC_VAR_INIT(100);
int main(int argc, const char* argv[])
{
// 这里在main函数中声明了局部原子对象a
atomic_int a;
// 我们通过atomic_init函数对原子对象a进行初始化为10
atomic_init(&a, 10);
// 我们通过atomic_store函数将原子对象a修改为20
atomic_store(&a, 20);
// 我们通过atomic_load函数将原子对象a的值加载到普通对象b中
int b = atomic_load(&a);
// 我们利用atomic_fetch_add函数,对原子对象sIntAtom与普通对象b做原子加法操作。
// 此时返回的结果是做原子加法操作之前的sIntAtom的值
int oldValue = atomic_fetch_add(&sIntAtom, b);
printf("oldValue = %d\n", oldValue);
// 我们将原子加法操作之后的sIntAtom原子对象的值,加载到对象b中
b = atomic_load(&sIntAtom);
printf("sIntAtom = %d\n", b);
}
代码清单1-9清晰而又精简地介绍了对原子对象的各类操作。这里给大家呈现的是对原子对象的初始化、加载、存储以及原子加法操作。除了原子加法操作之外,C11标准还定义了以下原子算术逻辑操作:atomic_fetch_sub(原子减法操作)、atomic_fetch_or(原子按位或操作)、atomic_fetch_xor(原子按位异或操作)、atomic_fetch_and(原子按位与操作)。这里要注意的是,这些算术逻辑原子操作都不能用于atomic_bool类型,即布尔原子类型。另外这里需要注意的是,对原子对象的初始化函数本身并非原子的,也就是说,atomic_init函数是可被打断的。因此我们在对原子对象做初始化时应当统一在一个线程中完成(通常是主线程),然后再做线程分派调度。另外,我们不应该使用atomic_store原子存储操作对原子对象进行初始化,对原子对象的初始化操作只有ATOMIC_VAR_INIT与atomic_init这两个接口。同时,其他原子操作必须作用于已初始化的原子对象,否则结果可能是未知的。
代码清单1-10将给大家带来一个比较实用的例子来描述原子对象以及原子操作的使用与效果。在这个例子中,我们将定义一个10000×100的一个int类型的二维数组,并对它的所有元素进行求和操作。我们将使用双核双线程并行计算来达成这个目的。
代码清单1-10 双核双线程对二维数组求和
#include <stdio.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <pthread.h>
//声明一个静态unsigned long long类型的原子对象,初始化为0,
// 用于存放原子计算操作的求和计算结果
static volatile atomic_ullong sAtomResult = ATOMIC_VAR_INIT(0);
// 声明一个静态的int类型的原子对象,初始化为0,
// 用于存放原子计算操作的当前计算数组的行索引
static volatile atomic_int sAtomIndex = ATOMIC_VAR_INIT(0);
// 声明一个静态普通的uint64_t类型对象,并将它初始化为0,
// 用于存放普通计算操作的求和计算结果
static volatile uint64_t sNormalResult = 0;
// 声明一个静态普通的int类型对象,并将它初始化为0,
// 用于存放普通计算操作中当前计算数组的行索引
static volatile int sNormalIndex = 0;
// 由于这个标志在用户线程中只写,且在主线程中只读,
// 因此在这两者线程中并不会产生数据竞争,所以无需使用原子对象
static volatile bool sIsThreadComplete = false;
// 声明即将用于计算的二维数组
static int sArray[10000][100];
// 定义普通计算操作的线程例程
static void* NormalSumProc(void *param)
{
// 这里使用一个currIndex对象,使得sNormalIndex在每次迭代中仅被读取一次,
// 减少外部修改的干扰
int currIndex;
// 在每次迭代时,先读取当前行索引的值,然后立即对它做递增操作
while((currIndex = sNormalIndex++) < 10000)
{
// 得到当前行索引之后,对当前行的数组做求和计算
uint64_t sum = 0;
for(int i = 0; i < 100; i++)
sum += sArray[currIndex][i];
sNormalResult += sum;
}
// 用户线程计算结束,将sIsThreadComplete标志置为true
sIsThreadComplete = true;
return NULL;
}
// 定义原子操作计算的线程例程
static void* AtomSumProc(void *param)
{
int currIndex;
while((currIndex = atomic_fetch_add(&sAtomIndex, 1))
< 10000)
{
uint64_t sum = 0;
for(int i = 0; i < 100; i++)
sum += sArray[currIndex][i];
atomic_fetch_add(&sAtomResult, sum);
}
sIsThreadComplete = true;
return NULL;
}
int main(int argc, const char* argv[])
{
// 我们先对sArray数组进行初始化
for(int i = 0; i < 10000; i++)
{
for(int j = 0; j < 100; j++)
sArray[i][j] = 100 * i + j;
}
// 我们先在主线程中计算出标准正确的计算结果
uint64_t standardResult = 0;
for(int i = 0; i < 10000; i++)
{
for(int j = 0; j < 100; j++)
standardResult += sArray[i][j];
}
printf("The standard result is: %llu\n", standardResult);
// 下面我们先观察不用原子对象与原子操作的计算
pthread_t threadID;
pthread_create(&threadID, NULL, &NormalSumProc, NULL);
// 在主线程中也做类似的计算处理
int currIndex;
// 使用原子加法操作对当前原子数组行索引做后缀递增操作
while((currIndex = sNormalIndex++) < 10000)
{
uint64_t sum = 0;
for(int i = 0; i < 100; i++)
sum += sArray[currIndex][i];
sNormalResult += sum;
}
// 等待用户线程完成
while(!sIsThreadComplete);
if(sNormalResult == standardResult)
puts("Normal compute compared equal!");
else
{
printf("Normal compute compared not equal: %llu\n",
sNormalResult);
}
// 我们最后对原子操作的线程做并行计算
sIsThreadComplete = false;
pthread_create(&threadID, NULL, &AtomSumProc, NULL);
while((currIndex = atomic_fetch_add(&sAtomIndex, 1))
< 10000)
{
uint64_t sum = 0;
for(int i = 0; i < 100; i++)
sum += sArray[currIndex][i];
atomic_fetch_add(&sAtomResult, sum);
}
// 等待用户线程完成
while(!sIsThreadComplete);
if(atomic_load(&sAtomResult) == standardResult)
puts("Atom compute compared equal!");
else
puts("Atom compute compared not equal!");
}
代码清单1-10不仅有原子操作的求和计算,而且还有不采用原子操作的求和计算。我们可以实际操作一下,能观察到若采用普通求和计算往往无法得到正确的计算结果,且计算结果的值每次执行还都不一样。这就是因为像++操作、+操作的非原子性造成的。像这类修改操作其实有三个步骤:读取数据、修改数据、存储数据。比如像a++;这个操作,如果用处理器指令来表示的话至少需要三条指令——load reg,[a](将对象a的值加载到reg寄存器中);inc reg(对reg寄存器做递增操作);store reg,[a](将reg寄存器的值再写回对象a中)。对于原子加法操作而言,它们将被组合成一单条指令,并且整个操作过程不能被打断。而对于普通操作,这三条指令每条执行完之后都能被打断,这就使得一个线程对寄存器做了修改之后,但在写回之前被其他线程先写回了,然后等该线程再写回就把先前线程修改的内容给覆盖了,从而造成了数据的不一致性。关于这个时序问题我们可以通过图1-1来清晰看到。
图1-1中上半部分是非原子的修改操作,下半部分是原子的修改操作。我们可以清晰地观察到对于非原子的读-修改-写操作之间存在着间隙,这些间隙都会被CPU利用,一旦有中断信号过来就会被打断,或者被其他处理器核心的相关操作给覆盖。像图12-1中,线程A与线程B几乎同时对一个共享存储单元读取值,然而线程A操作比较快,先写回数据,而线程B操作稍慢后写回,但是等到线程B写回数据的时候就直接把线程A已修改好的数据给完全覆盖了!换句话说,线程B并没有基于线程A先修改好的数据做相应操作。而原子操作则不一样,它们是作为一个整体的操作,如果同时有两个原子操作对同一共享存储单元进行操作,那么存储器控制器会做仲裁哪个操作优先、哪个操作断后,并且稍后执行的原子操作必定基于之前修改完的结果进行。因为一个原子操作在被允许操作之前,连读取操作都不会执行,而当存储器控制器允许某个原子操作执行时,那么读取-修改-写回这三个操作才会捆绑着执行。
图1-1 原子操作与非原子操作的顺序图
注意:当前Visual Studio Community 2017中的VS-Clang对原子操作的编译器后端还没支持好,所以各位如果要在Windows系统上测试代码清单1-9与代码清单1-10中的内容的话,请使用基于GCC的Mingw或纯Clang编译器。或参考GitHub上的代码:https://github.com/zenny-chen/simple-stdatomic-for-VS-Clang。这里提供了基于VS-Clang环境中内建函数对部分原子操作的实现。各位将此GitHub中的stdatomic.h以及stdatomic.c放到自己的工程项目中,然后用#include.“stdatomic.h”进行包含。
原子操作其实属于一个比较大的问题领域,这里仅仅揭露了其冰山一角。C11标准对于原子库还有相关的存储器次序这个概念,此外还有lock-free(无锁)同步算法所需的原子操作,这些更高级的话题我们将放到本书姊妹篇标准库卷做详细描述。
1.5 本章小结
本章内容属于C语言中比较高端的话题,也是比较晦涩难懂的部分。如果大家对本章所讲述的类型限定符掌握到驾驭自如的境界的话,那么离C语言大师也就不远了。对于本章,笔者认为对于大部分初学者来说光看一遍还远远不够,大家需要不断实践,然后再去巩固阅读,相信每次阅读都会有新的收获。
最后,在12.4节中提到_Atomic所使用的_Atomic(类型名)这种表达方式,如果我们在使用const、volatile以及restrict时,在不涉及指向数组的指针与指向函数的指针这些表达方式的情况下也可以这么玩,代码清单1-11将给大家呈现这种奇妙的表达方式。
代码清单1-11 有趣的类型限定符表达方式
#include <stdio.h>
// 我们自己定义一个类似于_Atomic用法的宏CONST
#define CONST(type) type const
int main(int argc, const char* argv[])
{
// 声明一个int类型的常量对象a,并初始化为10
CONST(int) a = 10;
int b = 0;
// 声明一个普通指针对象,指向a的地址
// (*p)的类型为const int
CONST(int) *p = &a;
// 声明一个常量指针对象q,指向对象b的地址
// q的类型为int * const
CONST(int*) q = &b;
*q += 20;
// 声明一个指针对象pp,指向指针p的地址
// 其类型为const int * const *
CONST(CONST(int)*) *pp = &p;
printf("The value is: %d\n", **pp + *q);
}
对于代码清单1-11我们可以看到,只有在CONST圆括号里包围的类型才是常量类型。像指针对象p,CONST所包围的是int类型,所以指针p本身不是常量,而(*p)才是。而指针对象q则相反,CONST包围的是int*,所以指针对象q本身是常量,但(*q)则不是。而对于比较复杂的pp对象,最外围的CONST包围的是CONST(int)*类型,所以pp自身不是一个常量;但(*pp)就是了,它的类型就是CONST(CONST(int)*);而(**pp)的类型则是CONST(int),明显也是个常量。
所以,只要把基本的概念掌握之后,我们可以自己抽象出一套解析方法。无论怎么变,万变不离其宗。
加油·哦!一点点鼓励能让我们继续坚持的动力,你是最棒的
下一章:C语言的类型系统
下一篇: C++类和对象的概念及定义