高效c语言2对象、函数和类型
  NJnxCrUH2njg 2023年11月02日 97 0
C++

本章中,你将学习对象、函数和类型。我们将研究如何声明变量(有标识符的对象)和函数,获取对象的地址,并对这些对象指针的解引用。你已经看到了C语言程序员可用的一些类型, C语言中的类型不是对象就是函数。

对象、函数、类型和指针

对象是你可以表示数值的存储。准确地说,C标准(ISO/IEC 9899:2018)将对象定义为 "执行环境中的数据存储区域,其内容可以代表数值",并补充说明,"当被引用时,对象可以被解释为具有特定类型"。变量是对象的例子。

变量会声明的类型,告诉你它的值代表哪种对象。例如类型为int的对象包含整数值。类型很重要,因为代表一种类型的对象的比特集合,如果被解释为不同类型的对象,可能会有不同的值。例如,数字1在IEEE 754(IEEE浮点运算标准)中由比特模式0x3f800000(IEEE 754-2008)表示。但是,如果你把这个相同的比特模式解释为一个整数,你会得到1,065,353,216的值,而不是1。
函数不是对象,但确实有类型。

C语言也有指针,它在地址--内存中存储对象或函数的位置。指针类型是由引用类型的函数或对象类型派生出来的。从被引用类型T派生出来的指针类型被称为对T的指针。

声明变量

声明变量时,需要指定类型,并提供名称用来引用该变量。

可以一行声明多个变量,但如果变量是指针或数组,或者变量是不同的类型,这样做就会引起混乱。下面的声明都是正确的。

char *src, c;
int x, y[5];
int m[12], n[15][3], o[21];

第一行声明了两个变量,src和c,它们的类型不同。src变量的类型为char *,而c的类型为char。第二行再次声明了两个类型不同的变量,x和y。变量x的类型是int,y是由五个元素组成的数组,类型是int。第三行声明了三个数组-m、n和o-具有不同的尺寸和元素数。

一行一种类型的声明可读性会更好:

char *src;    // src has a type of char *
char c;       // c has a type of char
int x;        // x has a type int
int y[5];     // y is an array of 5 elements of type int
int m[12];    // m is an array of 12 elements of type int
int n[15][3]; // n is an array of 15 arrays of 3 elements of type int
int o[21];    // o is an array of 21 elements of type int

实例:交换值1

在{ }字符之间有代码块称为复合语句。我们在主函数中定义了两个变量,a和b。我们声明这些变量的类型为int,并将它们分别初始化为21和17。每个变量都必须有一个声明。然后主函数调用swap函数来尝试交换这两个整数的值。

#include <stdio.h>

void swap(int a, int b) {
  	int t = a; 
  	a = b;
  	b = t;
  	printf("swap: a = %d, b = %d\n", a, b);
}

int main(void) {
    int a = 21;
    int b = 17;
    swap(a, b); 
    printf("main: a = %d, b = %d\n", a, b);
    return 0;
}

局部变量,如清单2-1中的a和b,具有自动存储期限,这意味着它们一直存在,直到执行离开它们被定义的块。我们将尝试交换存储在这两个变量中的值。

swap函数声明了两个参数,a和b,你用它们来向这个函数传递参数。我们还在交换函数中声明了int类型的临时变量t,并将其初始化为a的值。这个变量用于临时保存a中存储的值,以便在交换过程中不会丢失。

执行结果

$ ./a.out
swap: a = 17, b = 21
main: a = 21, b = 17

变量a和b分别被初始化为21和17。在swap函数中对printf的第一次调用显示这两个值被交换了,但在main中对printf的第二次调用显示原始值没有变化。

C语言是传值的语言,传参时参数的值被复制到一个单独的变量中,以便在函数中使用。swap函数将你作为参数传递的对象的值分配给各自的参数。当函数中的参数值发生变化时,调用方的值不会受到影响,因为它们是不同的对象。因此,在第二次调用printf时,变量a和b在main中保留了它们的原始值。

实例:交换值2

我们使用指示符(*)来声明指针

#include <stdio.h>

int swap(int *_a, int *_b) {
	int tmp = *_a;
	*_a = *_b;
	*_b = tmp;
}

int main(void) {
	int a = 21;
	int b = 17;
	swap(&a, &b);
	printf("a = %d, b = %d\n", a, b);
	return 0;
}

清单2-3:修改后的使用指针的交换函数

当在函数声明或定义中使用时,作为指针声明器的一部分,表示参数是指向特定类型的对象或函数的指针。注意_a表示指针,_a表示指针指向的值。&获取操作符的地址。

执行结果

$ ./a.out
a = 17, b = 21

变量a和b分别被初始化为21和17。然后代码将这些对象的地址作为参数传递给交换函数。

在swap函数中,参数_a和_b现在都被声明为int的指针类型,并且包含了从调用函数(在本例中,main)传递给swap的参数的副本。这些地址副本仍然指向完全相同的对象,所以当它们所引用的对象的值在交换函数中被交换时,在main中声明的原始对象的内容也被访问并被交换。这种方法通过生成对象地址,通过值传递这些地址,然后通过地址来访问原始对象,即传址。

范围

对象、函数、宏和其他C语言标识符都有范围,它限定了它们可以被访问的连续区域。C语言有四种类型的范围:文件、块、函数原型和函数。

对象或函数标识符的范围是由它的声明位置决定的。如果声明在任何块或参数列表之外为文件范围;如果声明出现在块内或参数内,只能在该块内访问。

如果声明出现在函数原型的参数声明列表中(不是函数定义的一部分),那么标识符具有函数原型作用域,它终止于函数声明末端; 函数范围是指函数定义的开头{和结尾}之间的区域。标签名是唯一一种函数作用域的标识符。标签是标识符,后面有一个冒号,用来标识函数中的一个语句,控制权可以被转移到这个语句中。

作用域可以被嵌套,有内部和外部作用域。内层作用域可以访问外层作用域,但反之不行。如果你在内层作用域和外层作用域中都声明了同一个标识符,那么在外层作用域中声明的标识符会被内层作用域中的标识符所隐藏,后者具有优先权。

存储期限

有四种存储期限可供选择:自动、静态、线程和分配。你已经看到,自动存储期限的对象被声明在块中或作为函数参数。这些对象的生命周期从它们被声明的块开始执行时开始,到块的执行结束时结束。

范围和寿命是完全不同的概念。范围适用于标识符,而寿命适用于对象。标识符的范围是代码区域,在这个区域中,标识符所表示的对象可以通过它的名字被访问。对象的生命周期是该对象存在的时间段。

在文件作用域中声明的对象具有静态存储期限。这些对象的生命期是程序的整个执行过程,它们的存储值在程序启动前被初始化。你也可以通过使用存储类指定符static,将块作用域中的变量声明为具有静态存储期限,如清单2-6中的计数例子所示。这些对象在函数退出后持续存在。

#include <stdio.h>

void increment(void) {
  static unsigned int counter = 0;
  counter++;
  printf("%d ", counter);
}

int main(void) {
  for (int i = 0; i < 5; i++) {
    increment();
  }
  return 0;
}

这个程序输出1 2 3 4 5。我们在程序启动时将静态变量counter初始化为0,并在每次调用increment函数时将其递增。计数器的生命周期是程序的整个执行过程,它将在整个生命周期内保留其最后存储的值。你可以通过用文件范围声明计数器来实现同样的行为。然而在可能的情况下,限制对象的范围是一种良好的软件工程实践。

静态对象必须用常量值而不是变量来初始化。

int *func(int i) {
  const int j = i; // ok
  static int k = j; // error
  return &k;
}

常量值指的是字面常量(例如,1、'a'或0xFF)、枚举成员以及alignof或sizeof等运算符的结果。

线程存储持续时间用于并发编程,动态分配的内存。

对齐

对象类型有对齐要求,对象可能被分配的地址进行限制。对齐代表了给定对象可以被分配的连续地址之间的字节数。CPU在访问对齐的数据(例如,数据地址是数据大小的倍数)和未对齐的数据时可能有不同的行为。

一些机器指令可以在非字的边界上执行多字节访问,但可能会有性能上的损失。字是自然的、固定大小的数据单位,由指令集或处理器的硬件处理。一些平台不能访问未对齐的内存。对齐要求可能取决于CPU字的大小(通常为16、32或64位)。

一般来说,C语言程序员不需要关心对齐要求,因为编译器为其各种类型选择合适的对齐方式。对于所有的标准类型,包括数组和结构,从malloc动态分配的内存都需要充分对齐。然而,在极少数情况下,你可能需要覆盖编译器的默认选择;例如,在必须从二幂地址边界开始的内存缓存行边界上对齐数据,或者满足其他系统特定的要求。传统上,这些要求是通过linker命令来满足的,或者通过malloc对内存进行整体分配,然后将用户地址向上舍入,或者通过涉及其他非标准设施的类似操作。

C11引入了一种简单的、向前兼容的机制来指定对齐方式。对齐是以size_t类型的值表示的。每个有效的对齐值都是的2整数次方。每个对象都有默认的对齐要求:更严格的对齐(更大的2次方)可以通过对齐指定器(_Alignas)来请求。你可以在声明的指定器中包个对齐方式的指定器。清单2-7使用对齐指定符来确保good_buff是正确对齐的(bad_buff对于成员访问表达式可能有不正确的对齐)。

struct S {
  int i; double d; char c;
};

int main(void) {
  unsigned char bad_buff[sizeof(struct S)];
  _Alignas(struct S) unsigned char good_buff[sizeof(struct S)];

  struct S *bad_s_ptr = (struct S *)bad_buff;   // wrong pointer alignment
  struct S *good_s_ptr = (struct S *)good_buff; // correct pointer alignment
}

对齐是按从弱到强(也叫严格)的顺序排列的。

对象类型

我们将介绍布尔类型、字符类型和数字类型(包括整数和浮点类型)。

布尔类型

声明为_Bool的对象只能存储0和1的值。这种布尔类型是在C99中引入的,并以下划线开始,以便在已经声明了自己的标识符名为bool或boolean的现有程序中加以区分。以下划线和大写字母或另一个下划线开头的标识符总是被保留。

如果你包含头文件<stdbool.h>,你也可以把这个类型拼成bool,并给它赋值为true(扩展为整数常数1)和false(扩展为整数常数0)。在这里,我们使用两种类型名称的拼写来声明两个布尔变量:

#include <stdbool.h>
_Bool flag1 = 0;
bool flag2 = false;

两种拼法都可以使用,但最好使用bool,因为这是语言的长期方向。

字符类型

C语言定义了三种字符类型:char、signed char和unsigned char。每个编译器的实现都会将char定义为具有相同的对齐方式、大小、范围、表示方式和行为,即signed char或nsigned char。无论做出什么样的选择,char都是独立的类型,与其他两种类型都不兼容。

char类型通常用于表示C语言程序中的字符数据。特别是,char类型的对象必须能够表示执行环境中所需要的最小字符集(称为基本执行字符集),包括大写和小写字母、10位小数、空格字符以及各种标点符号和控制字符。char类型不适合整数数据;使用signed char来表示小的有符号整数值,使用unsigned char来表示小的无符号值,是比较安全的。

基本的执行字符集适合许多传统的数据处理应用的需要,但它缺乏非英文字母是国际用户接受的障碍。为了解决这一需要,C标准委员会指定了一种新的、宽的类型,以允许大型字符集。你可以通过使用wchar_t类型将大字符集的字符表示为宽字符,它通常比基本字符占用更多空间。通常情况下,实现者选择16或32位来表示一个宽字符。C标准库提供了同时支持窄字符和宽字符类型的函数。

数值类型

C提供了几种数字类型,可以用来表示整数、枚举器和浮点值。第3章更详细地介绍了其中的一些类型,但这里是简单的介绍。

  • 整数类型

有符号的整数类型可以用来表示负数、正数和零。有符号的整数类型包括signed char、short int、int、long int和long long int。
除了int本身,在这些类型的声明中可以省略关键字int,所以你可以,例如,用long long而不是long long int来声明一个类型。
对于每个有符号的整数类型,都有相应的无符号整数类型,使用相同的存储量:unsigned char、unsigned short int、unsigned int、unsigned long int和unsigned long long int。无符号类型只能用来表示正数和零。

有符号和无符号整数类型用于表示各种大小的整数。每个平台(当前或历史)都决定了这些类型的大小,给定了一些约束条件。每个类型都有最小的可表示范围。这些类型按宽度排序,保证较宽的类型至少和较窄的类型一样大,这样,long long int类型的对象可以表示long int类型的对象可以表示的所有值,long int类型的对象可以表示int类型的对象可以表示的所有值,等等。各种整数类型的实际大小可以从<limits.h>头文件中指定的各种整数类型的最小和最大可表示值推断出来。

int类型通常具有执行环境的架构所建议的自然大小,因此在16位架构上,其大小为16位宽,在32位架构上为32位宽。你可以通过使用<stdint.h>或<inttypes.h>头文件的类型定义来指定实际宽度的整数,如uint32_t。这些头文件还为最宽的可用整数类型提供了类型定义:uintmax_t和intmax_t。
第3章详细介绍了整数类型。

  • 枚举类型

枚举,或称enum,允许你定义一个类型,在具有可枚举的常量值集的情况下,为整数值分配名称(枚举器)。下面是枚举的例子:

enum day { sun, mon, tue, wed, thu, fri, sat }; 
enum cardinal_points { north = 0, east = 90, south = 180, west = 270 };
enum months { jan = 1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec };

如果你没有用=操作符给第一个枚举器指定值,那么它的枚举常量的值就是0,而后面每个没有=的枚举器都会在前面的枚举常量的值上加1。因此,day枚举中sun的值是0,mon是1,以此类推。
你也可以给每个枚举器分配特定的值,如cardinal_points枚举所示。与枚举器一起使用=可能会产生具有重复值的枚举常数,如果你错误地认为所有的值都是唯一的,这可能是一个问题。months枚举将第一个枚举器设置为1,每个后续的枚举器如果没有被特别指定一个值,将被递增1。
枚举常量的实际值必须可以作为int来表示,但是它的类型是由实现定义的。例如,Visual C++使用有符号的int,而GCC使用无符号的int。

  • 浮点类型

C语言支持三种浮点类型:float、double和long double。浮点运算类似于实数运算,并经常被用作实数运算的模型。C语言支持多种浮点表示法,包括在大多数系统上支持IEEE浮点算术标准(IEEE 754-2008)。浮点表示法的选择取决于实现。第3章详细介绍了浮点类型。

  • void类型
    关键字void(本身)的意思是 "不能容纳任何值"。例如,你可以用它来表示函数不返回值,或者作为函数的唯一参数来表示该函数不接受参数。另一方面,派生类型void *意味着指针可以引用任何对象。我将在本章后面讨论派生类型。

函数类型

函数类型是派生类型。在这种情况下,该类型是由返回类型和其参数的数量和类型衍生出来的。函数的返回类型不能是数组类型。
当你声明函数时,你使用函数声明器来指定函数的名称和返回类型。如果声明器包括参数类型列表和定义,每个参数的声明必须包括标识符,除了只有void类型参数的参数列表,它不需要标识符。
下面是几个函数类型的声明:

int f(void);
int *fip();
void g(int i, int j);
void h(int, int);

首先,我们声明没有参数的函数f,返回int。接下来,我们声明一个没有指定参数的函数fip,它返回指向int的指针。最后,我们声明两个函数,g和h,每个函数都返回void,并接受两个int类型的参数。
如果标识符是宏,用标识符来指定参数(如这里的g)可能会有问题。然而,提供参数名称是自我记录代码的良好做法,所以通常不建议省略标识符(如对h的做法)。
在函数声明中,指定参数是可选的。然而,不这样做偶尔也会有问题。如果你用C++写fip的函数声明,它将声明不接受任何参数的函数,并返回int *。在C语言中,fip声明的是接受任何类型参数的函数,并返回一个int *。在C语言中,你不应该用空参数列表来声明函数。首先,这是语言的废弃功能,将来可能会被删除。其次,这段代码可能会被移植到C++中,所以要明确地列出参数类型,在没有参数的时候使用void。
带有参数类型列表的函数类型被称为函数原型。函数原型告知编译器一个函数所接受的参数的数量和类型。编译器使用这些信息来验证在函数定义和对函数的任何调用中是否使用了正确的参数数量和类型。

函数定义提供了该函数的实际实现。请看下面的函数定义:

int max(int a, int b)
{ return a > b ? a : b; }

返回类型指定为int;函数声明器为max(int a, int b);而函数主体为{ return a > b ? a : b; }。函数类型的指定不能包括任何类型限定符(参见第32页 "类型限定符")。函数体本身使用了条件运算符(?😃,这将在第4章进一步解释。这个表达式指出,如果a大于b,返回a;否则,返回b。

派生类型

派生类型是由其他类型构建的类型。这些类型包括指针、数组、类型定义、结构和联合,我们将在这里介绍所有这些类型。

指针类型

指针类型是从它所指向的函数或对象类型派生出来的,称为被引类型。指针提供了对被引用类型的实体的引用。
下面的三个声明声明了指向int的指针,指向char的指针,以及指向void的指针:

int *ip;
char *cp;
void *vp;

在本章的前面,我介绍了address-of(&)和indirection(*)操作符。你使用&操作符来获取对象或函数的地址。例如,如果对象是一个int,操作符的结果就有int的指针类型:

int i = 17;
int *ip = &i;

我们将变量ip声明为int的指针,并将i的地址分配给它。你也可以对*运算符的结果使用&运算符:

ip = &*ip;

通过使用间接操作符解除对ip的引用,可以解析到实际的对象i。通过使用&操作符获取ip的地址,可以检索到指针,所以这两个操作是相互抵消的。
单元的
运算符将类型的指针转换为该类型的值。它对指针进行操作。如果操作数指向函数,使用*运算符的结果是函数的代号,如果指向对象,结果是指定对象的值。例如,如果操作数是指向int的指针,那么转折运算符的结果就有int类型。如果指针没有指向一个有效的对象或函数,可能会发生不好的事情。

数组

数组是连续分配的对象序列,它们都具有相同的元素类型。数组类型的特点是其元素类型和数组中的元素数量。这里我们声明了由11个元素组成的数组,其类型为int,标识为ia,还有由17个元素组成的数组,其类型为指针浮点,标识为afp:

int ia[11];
float *afp[17];

你可以使用方括号([])来标识一个数组的元素。例如,下面的代码片段创建了字符串 "0123456789 "来演示如何为数组中的元素赋值:

char str[11];
for (unsigned int i = 0; i < 10; ++i) {
  str[i] = '0' + i;
}
str[10] = '\0';

第一行声明了大小为11的char数组,这分配了足够的存储空间来创建10个字符和空字符的字符串。for循环迭代了10次,i的值从0到9不等。每次迭代都将表达式'0'+i的结果分配给str[i]。在循环结束后,空字符被复制到数组str[10]。

str被自动转换为指向数组第一个成员的指针(char类型的对象),而i具有无符号整数类型。下标([])运算符和加法(+)运算符被定义,因此str[i]与*(str + i)相同。当str是一个数组对象时(如这里),表达式str[i]指定数组的第i个元素(从0开始计算)。因为数组的索引是从0开始的,所以数组char str[11]的索引是从0到10,10是最后一个元素,如本例最后一行所引用。
如果单数&运算符的操作数是[]运算符的结果,其结果就像去掉&运算符并将[]运算符改为+运算符一样。例如,&str[10]与str + 10相同。
你也可以声明多维数组。清单2-8在函数main中声明arr是int类型的二维5×3数组,也被称为矩阵。

void func(int arr[5]);
int main(void) {
  unsigned int i = 0;
  unsigned int j = 0;
  int arr[3][5];
  func(arr[i]);
  int x = arr[i][j];
  return 0;
}

清单2-8:矩阵操作

更确切地说,arr是由三个元素组成的数组,每个元素都是由五个int类型的元素组成的数组。

类型定义

你使用typedef来声明现有类型的别名;它从不创建新的类型。例如,下面的每个声明都创建了新的类型别名:

typedef unsigned int uint_type; 
typedef signed char schar_type, *schar_p, (*fp)(void);

在第一行,我们声明uint_type是无符号int类型的别名。在第二行,我们声明schar_type是有符号char的别名,schar_p是有符号char 的别名,fp是有符号char()(void)的别名。在标准头文件中以_t结尾的标识符是类型定义(现有类型的别名)。一般来说,你不应该在自己的代码中遵循这个惯例,因为C标准保留了符合int[0-9a-z_]t和uint[0-9a-z]_t模式的标识符,而便携式操作系统接口(POSIX)保留了所有以_t结尾的标识符。如果你定义了使用这些名称的标识符,它们可能会与实现所使用的名称发生冲突,这可能会导致难以调试的问题。

结构体

结构体包含按顺序分配的成员对象。每个对象都有自己的名字,并且可以有不同的类型--与数组不同,数组必须都是相同的类型。结构类似于其他编程语言中的记录类型。清单2-9声明了由sigline标识的对象,其类型为struct sigrecord,并有指向由sigline_p标识的sigline对象的指针。

struct sigrecord {
   int signum;
   char signame[20];
   char sigdesc[100];
} sigline, *sigline_p;

结构体对于声明相关对象的集合很有用,可以用来表示诸如日期、客户或人事记录等。它们对于将经常一起作为参数传递给函数的对象分组特别有用,因此你不需要重复地分别传递单个对象。
一旦你定义了一个结构,你可能想引用它的成员。你可以通过使用结构成员(.)操作符来引用结构类型的对象的成员。如果你有一个指向结构的指针,你可以用结构指针(->)操作符来引用其成员。清单2-10演示了每个操作符的使用。

  sigline.signum = 5;
  strcpy(sigline.signame, "SIGINT");
  strcpy(sigline.sigdesc, "Interrupt from keyboard");

  sigline_p = &sigline; 

  sigline_p->signum = 5;
  strcpy(sigline_p->signame, "SIGINT");
  strcpy(sigline_p->sigdesc, "Interrupt from keyboard");

联合体

联盟类型与结构类似,只是成员对象使用的内存是重叠的。联合类型可以在某一时刻包含一个类型的对象,在另一时刻包含一个不同类型的对象,但绝不会同时包含两个对象,它主要用于节省内存。清单2-11显示了包含三个结构的联盟u:n、ni和nf。这个联合体可能用在树、图或其他数据结构中,这些结构的一些节点包含整数值(ni),其他节点包含浮点值(nf)。

union {
  struct {
    int type;
  } n;
  struct {
    int type;
    int intnode;
  } ni;
  struct {
    int type;
    double doublenode;
  } nf;
} u;
u.nf.type = 1;
u.nf.doublenode = 3.14;

和结构一样,你可以通过.操作符来访问联盟成员。使用指向联合体的指针,你可以用->操作符来引用它的成员。在清单 2-11 中,联盟的 nf 结构中的 type 成员被引用为 u.nf.type,而 doublenode 成员被引用为 u.nf .doublenode。使用这个联盟的代码通常会通过检查存储在u.n.type中的值来检查节点的类型,然后根据类型来访问intnode或doublenode结构。如果这被实现为结构,每个节点将包含intnode和doublenode成员的存储。使用联合体可以为两个成员使用相同的存储。

Tags

标签是结构体、联合体和枚举的一种特殊命名机制。例如,出现在下面结构中的标识符s就是一个标签:

struct s {
  //---snip---
};

标签本身不是一个类型名,不能用来声明变量(Saks 2002)。相反,你必须按以下方式声明该类型的变量:

struct s v;   // instance of struct s
struct s *p;  // pointer to struct s

联合体和枚举的名称也是标记,而不是类型,这意味着它们不能单独用来声明一个变量。比如说

enum day { sun, mon, tue, wed, thu, fri, sat }; 
day today;  // error
enum day tomorrow;  // OK

结构、联合体和枚举的标记被定义在与普通标识符不同的命名空间中。这允许C程序在同一范围内同时拥有一个标签和另一个拼写相同的标识符:

enum status { ok, fail };  // enumeration
enum status status(void);  // function

你甚至可以声明类型为struct s的对象s:

struct s s;

这可能不是好的做法,但在C语言中是有效的。你可以把结构标签看作是类型名,并通过使用typedef为标签定义别名。下面是一个例子:

typedef struct s { int x; } t;

现在您可以声明类型为t的变量,而不是结构为s的变量。结构、联合和枚举中的标记名称是可选的,所以您可以完全省略它:

typedef struct { int x; } t;

除了包含指向自身的指针的自指结构外,这样做效果不错:

struct tnode {
  int count;
  struct tnode *left;
  struct tnode *right;
};

如果你省略了第一行的标记,编译器可能会抱怨,因为第3行和第4行的引用结构还没有被声明,或者因为整个结构没有被联合体和枚举的名称也是标记,而不是类型,这意味着它们不能单独用来声明一个变量。比如说

typedef struct tnode {
  int count;
  struct tnode *left;
  struct tnode *right;
} tnode;

大多数C程序员为标签和typedef使用了不同的名字,但相同的名字也可以使用。你也可以在结构之前定义这个类型,这样你就可以用它来声明引用其他tnode类型对象的左右成员:

typedef struct tnode tnode;
struct tnode {
  int count;
  tnode *left
  tnode *right;
} tnode;

类型定义可以提高代码的可读性,不仅仅是用于结构。例如,下面三个信号函数的声明都指定了相同的类型:

typedef void fv(int), (*pfv)(int);
void (*signal(int, void (*)(int)))(int);
fv *signal(int, fv *);
pfv signal(int, pfv);

类型限定词

到目前为止所研究的所有类型都是未限定的类型。类型可以通过使用以下一个或多个限定符来限定:const, volatile, 和 restrict。当访问限定类型的对象时,这些限定符都会改变行为。

类型的限定和非限定版本可以互换使用,作为函数的参数、函数的返回值和联合体的成员。

注意:从C11开始使用的_Atomic类型限定符,支持并发程序。

  • const

用const修饰符声明的对象(const-qualified类型)是不可修改的。特别是,它们不能被分配,但可以有常量初始化器。这意味着具有const-qualified类型的对象可以被编译器放在只读内存中,任何试图写到它们的行为都会导致运行时错误:

const int i = 1; // const-qualified int
i = 2; // error: i is const-qualified

有可能意外地说服你的编译器为你改变一个const-qualified的对象。在下面的例子中,我们取了常量限定的对象i的地址,并告诉编译器这实际上是一个指向int的指针:

const int i = 1;  // object of const-qualified type
int *ip = (int *)&i;
*ip = 2;  // undefined behavior

如果原来的对象被声明为常数限定的对象,C语言不允许你抛开常数。这段代码可能看起来是有效的,但它是有缺陷的,以后可能会失败。例如,编译器可能会把const-qualified对象放在只读内存中,当运行时试图在该对象中存储一个值时,会引起内存故挥发性障。

C语言允许你修改一个由常量限定的指针指向的对象,只要原对象没有被声明为常量:

int i = 12;
const int j = 12;
const int *ip = &i;
const int *jp = &j;
*(int *)ip = 42; // ok
*(int *)jp = 42; // undefined behavior
  • volatile

volatile-qualified类型的对象有一个特殊的用途。静态volatile-qualified对象被用来模拟内存映射的输入/输出(I/O)端口,静态常量volatile-qualified对象被用来模拟内存映射的输入端口,如实时时钟。

存储在这些对象中的值可能在编译器不知情的情况下发生变化。例如,每次读取实时时钟的值时,它可能会改变,即使该值没有被C语言程序写入。使用 volatile-qualified 类型让编译器知道值可能会改变,并确保对实时时钟的每次访问都会发生(否则,对实时时钟的访问可能会被优化掉或被先前读取和缓存的值取代)。用在任何地方。因此,你别无选择,只能为该结构声明一个标签,但你也可以声明一个类型定义:

volatile int port;
port = port;

如果没有volatile限定,编译器会把它看作是no-op(什么都不做的编程语句),并有可能消除读和写。
另外,volatile限定的类型用于与信号处理程序和setjmp/longjmp的通信(关于信号处理程序和setjmp/longjmp的信息请参考C标准)。与Java和其他编程语言不同,C语言中的volatile-qualified类型不应该用于线程之间的同步。

  • restrict

限制性限定的指针用于促进优化。通过指针间接访问的对象经常不能被完全优化,因为潜在的别名,当一个以上的指针指向同一个对象时就会发生。别名可以抑制优化,因为编译器无法判断一个对象的部分在另一个明显不相关的对象被修改时是否可以改变值,例如。
下面的函数从q引用的存储空间复制了n个字节到p引用的存储空间,函数参数p和q都是限制性限定的指针:

void f(unsigned int n, int * restrict p, int * restrict q) {
  while (n-- > 0) {
    *p++ = *q++;
  }
}

因为p和q都是限制性限定的指针,编译器可以假定通过其中一个指针参数访问的对象不会同时通过另一个指针参数访问。编译器可以只根据参数声明来做这个评估,而不分析函数体。尽管使用限制性限定的指针可以产生更有效的代码,但你必须确保指针不指向重叠的内存,以防止未定义的行为。

【版权声明】本文内容来自摩杜云社区用户原创、第三方投稿、转载,内容版权归原作者所有。本网站的目的在于传递更多信息,不拥有版权,亦不承担相应法律责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@moduyun.com

  1. 分享:
最后一次编辑于 2023年11月08日 0

暂无评论

推荐阅读
  8Tw5Riv1mGFK   2024年05月01日   80   0   0 C++
  BYaHC1OPAeY4   2024年05月08日   57   0   0 C++
  yZdUbUDB8h5t   2024年05月05日   43   0   0 C++
  oXKBKZoQY2lx   2024年05月17日   57   0   0 C++
NJnxCrUH2njg