9、define、typedef、const和volatile
1、宏定义define
#define 叫做宏定义命令,它也是C语言预处理命令的一种。所谓宏定义,就是用一个标识符来表示一个字符串,如果在后面的代码中出现了该标识符,那么就全部替换成指定的字符串。
(1)、无参宏定义
格式:#define 宏名 字符串
#表示这是一条预处理命令,所有的预处理命令都以 # 开头。宏名是标识符的一种,命名规则和变量相同。字符串可以是数字、表达式、if 语句、函数等。
字符串可以是常量、表达式、格式串等
注意:字符串为表达式时该加括号时记得加括号,如果字符串后面有分号会连分号一同替换。
例如:
#define N 100
N为宏名,100是宏的内容(宏所表示的字符串)。在预处理阶段,对程序中所有出现的“宏名”,预处理器都会用宏定义中的字符串去代换,这称为“宏替换”或“宏展开”。
宏定义是由源程序中的宏定义命令#define完成的,宏替换是由预处理程序完成的。
宏定义只是简单的字符串代换,是在预处理完成的,而typedef是在编译时处理的,它不是简单地代换,而是对类型说明符重新命名。
宏定义的作用域包括从宏定义命名起到源程序结束,如果要终止其作用域可以使用#undef命令来取消宏定义:
格式:#undef 标识符
(2)、带参宏定义
格式:#define 宏名(形参表) 字符串
注意:宏名域形参表之间不能有空格出现,否则会把形参当做字符串处理
C语言允许宏带有参数。在宏定义中的参数称为“形式参数”,在宏调用中的参数称为“实际参数”,这点和函数有些类似。对带参数的宏,在展开过程中不仅要进行字符串替换,还要用实参去替换形参。
带参宏定义和函数的区别:
带参数的宏和函数很相似,但有本质上的区别:宏展开仅仅是字符串的替换,不会对表达式进行计算;宏在编译之前就被处理掉了,它没有机会参与编译,也不会占用内存。而函数是一段可以重复使用的代码,会被编译,会给它分配内存,每次调用函数,就是执行这块内存中的代码。
(3)C语言宏参数的字符串化和宏参数的连接
在宏定义中,有时还会用到#和##两个符号,它们能够对宏参数进行操作:
#用来将宏参数转换为字符串,也就是在宏参数的开头和末尾添加引号。例如有如下宏定义:
#define STR(s) #s
那么:
printf("%s", STR(c.biancheng.net));
printf("%s", STR("c.biancheng.net"));
分别被展开为:
printf("%s", "c.biancheng.net");
printf("%s", "\"c.biancheng.net\"");
可以发现,即使给宏参数“传递”的数据中包含引号,使用#仍然会在两头添加新的引号,而原来的引号会被转义。
##称为连接符,用来将宏参数或其他的串连接起来。例如有如下的宏定义:
#define CON1(a, b) a##e##b
#define CON2(a, b) a##b##00
那么:
printf("%f\n", CON1(8.5, 2));
printf("%d\n", CON2(12, 34));
将被展开为:
printf("%f\n", 8.5e2);
printf("%d\n", 123400);
对 #define 用法的几点说明
(1)宏定义是用宏名来表示一个字符串,在宏展开时又以该字符串取代宏名,这只是一种简单粗暴的替换。字符串中可以含任何字符,它可以是常数、表达式、if 语句、函数等,预处理程序对它不作任何检查,如有错误,只能在编译已被宏展开后的源程序时发现。
(2)宏定义不是说明或语句,在行末不必加分号,如加上分号则连分号也一起替换。
(3)宏定义必须写在函数之外,其作用域为宏定义命令起到源程序结束。如要终止其作用域可使用#undef命令。
(4)代码中的宏名如果被引号包围,那么预处理程序不对其作宏代替。
(5)宏定义允许嵌套,在宏定义的字符串中可以使用已经定义的宏名,在宏展开时由预处理程序层层代换。
(6)习惯上宏名用大写字母表示,以便于与变量区别。但也允许用小写字母。
(7)可用宏定义表示数据类型,使书写方便。
应注意用宏定义表示数据类型和用 typedef 定义数据说明符的区别。宏定义只是简单的字符串替换,由预处理器来处理;而 typedef 是在编译阶段由编译器处理的,它并不是简单的字符串替换,而给原有的数据类型起一个新的名字,将它作为一种新的数据类型。
2、typedef数据类型重命名
typedef是C语言的关键字,其作用是为一种数据类型定义一个新名字。这里的数据类型包括内部数据类型(char、int等)和自定义数据类型(struct等)。
格式:typedef 数据类型 自定义数据类型;
在大型程序开发中,typedef的应用非常广泛。目的有两点,一是给变量一个易记且意义明确的新名字,二是简化一些比较复杂的类型声明。
2.1、typedef 和 #define 的区别
typedef 在表现上有时候类似于 #define,但它和宏替换之间存在一个关键性的区别。正确思考这个问题的方法就是把 typedef 看成一种彻底的“封装”类型,声明之后不能再往里面增加别的东西。
(1)可以使用其他类型说明符对宏类型名进行扩展,但对 typedef 所定义的类型名却不能这样做。如下所示:
#define INTERGE int unsigned INTERGE n; //没问题
typedef int INTERGE; unsigned INTERGE n; //错误,不能在 INTERGE 前面添加 unsigned
(2)在连续定义几个变量的时候,typedef 能够保证定义的所有变量均为同一类型,而 #define 则无法保证。例如:
#define PTR_INT int * PTR_INT p1, p2;
经过宏替换以后,第二行变为:
int *p1, p2;
这使得 p1、p2 成为不同的类型:p1 是指向 int 类型的指针,p2 是 int 类型。
相反,在下面的代码中:
typedef int * PTR_INT PTR_INT p1, p2;
p1、p2 类型相同,它们都是指向 int 类型的指针。
(3)执行时间不同
关键字typedef在编译阶段有效,由于是在编译阶段,因此typedef有类型检查的功能。
#define则是宏定义,发生在预处理阶段,也就是编译之前,它只进行简单而机械的字符串替换,而不进行任何检查。
(4)、功能有差异
typedef用来定义类型的别名,定义与平台无关的数据类型,与struct的结合使用等。
#define不只是可以为类型取别名,还可以定义常量、变量、编译开关等。
(5)、作用域不同
#define没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用。
而typedef有自己的作用域。
3、const
有时候我们希望定义这样一种变量,它的值不能被改变,在整个作用域中都保持固定。例如,用一个变量来表示班级的最大人数,或者表示缓冲区的大小。为了满足这一要求,可以使用const关键字对变量加以限定:
我们经常将 const 变量称为常量(Constant)。创建常量的格式通常为:
const type name = value;
const 和 type 都是用来修饰变量的,它们的位置可以互换,也就是将 type 放在 const 前面:
type const name = value;
但我们通常采用第一种方式,不采用第二种方式。另外建议将常量名的首字母大写,以提醒程序员这是个常量。
由于常量一旦被创建后其值就不能再改变,所以常量必须在定义的同时赋值(初始化),后面的任何赋值行为都将引发错误。
(1)用const来定义一个常量
int const a; const int a;
定义了a为整形常量,只能读取a的值,不能更改a的值。a的值一般在定义时对其进行初始化,比如: int const a = 10;
如果在作为函数形参时,会在函数调用时对其进行初始化。
(2)用const来定义一个指针常量
const int *p1; int const *p2; int * const p3;
在最后一种情况下,指针是只读的,也就是 p3 本身的值不能被修改;在前面两种情况下,指针所指向的数据是只读的,也就是 p1、p2 本身的值可以修改(指向不同的数据),但它们指向的数据不能被修改。
当然,指针本身和它指向的数据都有可能是只读的,下面的两种写法能够做到这一点:
const int * const p4; int const * const p5;
const 和指针结合的写法多少有点让初学者摸不着头脑,大家可以这样来记忆:const 离变量名近就是用来修饰指针变量的,离变量名远就是用来修饰指针指向的数据,如果近的和远的都有,那么就同时修饰指针变量以及它指向的数据。
(3)const 和函数形参
在C语言中,单独定义 const 变量没有明显的优势,完全可以使用#define命令代替。const 通常用在函数形参中,如果形参是一个指针,为了防止在函数内部修改指针指向的数据,就可以用 const 来限制。
在C语言标准库中,有很多函数的形参都被 const 限制了,下面是部分函数的原型:
size_t strlen ( const char * str ); int strcmp ( const char * str1, const char * str2 ); char * strcat ( char * destination, const char * source ); char * strcpy ( char * destination, const char * source ); int system (const char* command); int puts ( const char * str ); int printf ( const char * format, ... );
(4)const 和非 const 类型转换
当一个指针变量 str1 被 const 限制时,并且类似const char *str1这种形式,说明指针指向的数据不能被修改;如果将 str1 赋值给另外一个未被 const 修饰的指针变量 str2,就有可能发生危险。因为通过 str1 不能修改数据,而赋值后通过 str2 能够修改数据了,意义发生了转变,所以编译器不提倡这种行为,会给出错误或警告。
也就是说,const char *和char *是不同的类型,不能将const char *类型的数据赋值给char *类型的变量。但反过来是可以的,编译器允许将char *类型的数据赋值给const char *类型的变量。
这种限制很容易理解,char *指向的数据有读取和写入权限,而const char *指向的数据只有读取权限,降低数据的权限不会带来任何问题,但提升数据的权限就有可能发生危险。
C语言标准库中很多函数的参数都被 const 限制了,但我们在以前的编码过程中并没有注意这个问题,经常将非 const 类型的数据传递给 const 类型的形参,这样做从未引发任何副作用,原因就是上面讲到的,将非 const 类型转换为 const 类型是允许的。
4、volatile
volatile是一个C/C++关键字,它用于告诉编译器当前变量是易变的,需要在每次使用时都从内存中重新获取值,而不是使用缓存中的旧值。
一般来说,对于定义在函数中的自动变量,编译器会尽量利用寄存器来提高访问速度,这样就会使得该变量的值可能被缓存起来,不一定会立即被写入内存。如果这时候要访问这个变量的值,就可能会出现错误的结果。特别是在并发编程或者嵌入式开发等场景下,可能会对内存进行随时修改,此时使用`volatile`关键字就可以防止出现这种错误。
使用`volatile`关键字时需要注意,它仅仅告诉编译器该变量是易变的,不应该使用寄存器来存储其值,但并不保证进程访问该变量的正确性,仍然需要考虑对多线程的并发安全问题,要遵循原子操作和加锁等操作。
可以举一个多线程环境下使用`volatile`关键字的例子。假设有一个共享资源`count`,多个线程同时访问它,其中一个线程会不断地修改`count`的值,其他线程需要读取`count`的值。这时候可以使用`volatile`关键字来确保其他线程读取到的都是最新的`count`的值,而不是之前被缓存的值。
#include <stdio.h> #include <stdlib.h> #include <pthread.h> #include <unistd.h> #define NTHREADS 10 volatile int count = 0; void *counter(void *arg) { while (1) { sleep(1); // 模拟计数的时间 count += 1; } return NULL; } int main() { pthread_t threads[NTHREADS]; int i, ret; // 创建NTHREADS个线程,都执行counter函数 for (i = 0; i < NTHREADS; ++i) { ret = pthread_create(&threads[i], NULL, counter, NULL); if (ret != 0) { fprintf(stderr, "Error creating thread %d\n", i); exit(EXIT_FAILURE); } } // 主线程负责读取count的值,并输出 while (1) { printf("count = %d\n", count); sleep(1); } // 等待子线程结束 for (i = 0; i < NTHREADS; ++i) { pthread_join(threads[i], NULL); } return 0; }
在上面的代码中,`count`变量定义时使用了`volatile`关键字,这样子线程每次修改`count`值之后,其他线程读取到的都是最新的`count`值。如果不使用`volatile`关键字,则可能读取到旧值,导致错误的结果。 此外,volatile还有阻止程序优化的作用。 在 C/C++ 中,`volatile` 关键字用于告诉编译器某个变量可能会在程序的外部被修改,从而避免编译器对该变量进行过度的优化。 如果没有使用 `volatile`,编译器可能会认为某个变量的值在整个程序执行期间都没有发生变化,从而对其进行优化,如将其存储在寄存器中,直接使用寄存器中的值而不是读取该变量的实际值。但如果该变量是被其他线程或硬件设备修改的(例如一个硬件 I/O 寄存器的值),那么编译器的优化可能会导致程序出错。 因此,使用 `volatile` 关键字可以告诉编译器,该变量的值是不稳定的,可能会在程序执行期间被修改,编译器必须每次读取该变量的实际值而不是使用寄存器中的旧值。 需要注意的是,`volatile` 并不能保证线程安全或原子性,它只能避免编译器对该变量的过度优化,而并不能保证其他线程或硬件设备对其的修改操作。如果需要保证线程安全和原子性,通常需要使用更高级别的同步原语,例如互斥锁、条件变量、原子变量等。 下面是一个简单的例子,用来说明 `volatile` 关键字是如何阻止编译器进行优化的。
#include <stdio.h> volatile int count = 0; // 定义一个 volatile 变量 count int main() { int i; for(i = 0; i < 1000000; i++) { count++; // 对 count 进行单纯的自增操作 } printf("count = %d\n", count); return 0; }
在这个程序中,我们定义了一个全局变量 `count`,并使用 `volatile` 关键字修饰它,告诉编译器该变量可能会被程序外部修改。
在 `main` 函数中,我们对 `count` 变量进行 1000000 次自增操作,并最终输出它的值。如果没有使用 `volatile` 关键字,编译器可能会认为程序中没有其他地方对 `count` 进行修改,从而对它进行过度优化。
为了验证这一点,我们可以使用 `-O3` 选项开启 GCC 的最高级别优化,再使用 `objdump` 命令查看编译后的汇编代码:
$ gcc -O3 -c -o test.o test.c
$ objdump -d test.o
编译后的汇编代码中,可以看到很多关于 `count` 的读取和运算都被优化成了寄存器操作,如下所示:
0000000000000000 <main>: 0: b8 c0 9a 3b 00 mov $0x3b9ac0,%eax 5: 89 c3 mov %eax,%ebx 7: b9 38 00 00 00 mov $0x38,%ecx c: 83 e9 01 sub $0x1,%ecx ... 6e: 83 e8 01 sub $0x1,%eax 71: 75 f6 jne 69 <main+0x69> 73: 89 d8 mov %ebx,%eax 75: c3 retq
可以看到,编译器将 `count` 的值存储在 `%eax` 寄存器中,并且每次自增操作都是直接修改该寄存器的值,而没有读取 `count` 的实际值。
但是,由于我们使用了 `volatile` 关键字,编译器会在编译期间禁止对 `count` 进行过度优化,从而保证每次自增都会读取 `count` 的实际值,即使程序外部可能会修改 `count`。
在经过 `-O3` 优化后编译后的汇编代码,即使加上 `volatile`,某些语句仍然可能会发生优化。
因此,需要注意,使用 `volatile` 并不能完全保证程序的正确性,只能在特定场合下避免编译器对某些变量进行过度优化。如果需要确保程序正确性,请使用更加严格的同步原语,如互斥锁、条件变量等。


浙公网安备 33010602011771号