Skip to content
~/nihildigit
Go back

EffectiveC_02_对象、函数和类型

在本章中,你将学习对象、函数和类型。我们将探讨如何声明变量(带有标识符的对象)和函数,获取对象的地址,并解引用那些对象指针。你已经看到了C程序员可用的一些类型。你在本章中将学到的第一件事是我很久之后才意识到的:C中的每一个类型要么是一个对象类型,要么是一个函数类型。

对象、函数、类型和指针

一个对象是一块用来表示值的存储区域。准确来说,C标准(ISO/IEC 9899:2018)将对象定义为”执行环境中的数据存储区域,其内容可以表示值”,并且”在引用时,可以将对象解释为具有特定类型。” 变量就是一种对象。

变量具有声明的类型,告诉您其值表示的对象类型。例如,具有int类型的对象包含整数值。
类型很重要,因为表示一个类型对象的位集合,如果解释为不同类型的对象,可能会具有不同的值。例如,数字1在IEEE 754(IEEE浮点运算标准)中用位模式0x3f800000表示(IEEE 754-2008)。但如果您将相同的位模式解释为整数,您将得到1065353216而不是1

函数不是对象,但具有类型,函数类型由其返回类型以及参数的数量和类型来描述。

C语言中还有指针,可以将其看作是地址------内存中存储对象或函数的位置。
指针类型由指针指向的类型所确定,一个指向整型的指针可以被称为整型指针

由于对象和函数是不同的东西,因此对象指针和函数指针也是不同的东西,不应该相混淆。在接下来的部分中,您将编写一个简单的程序,尝试交换两个变量的值,以帮助您更好地理解对象、函数、指针和类型。

声明变量

当您声明一个变量时,您为它分配一个类型并为它提供一个名称,或称为标识符,以便引用该变量。

清单2-1中,声明了两个具有初始值的整数对象。这个简单的程序还声明了一个名为”swap”的函数,但没有定义该函数来交换这些值。

#include <stdio.h>

void swap(int, int); //函数定义在清单2-2中

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:交换两个整数

这个示例程序展示了包含在{}字符之间的主函数的单一代码块,这被称为复合语句

我们在main函数内定义了两个变量ab,并且声明这些变量的类型为int,分别将它们初始化为2117。然后,main数调用swap函数来尝试交换这两个整数的值。程序中声明了swap函数,但没有定义。我们将在本节的后面部分看一些可能的实现方式。

声明多个变量

您可以在任何单个声明中声明多个变量,但如果这些变量是指针或数组,或者这些变量是不同的类型,这样做可能会变得混乱。例如,以下声明都是正确的:

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

第一行声明了两个变量srcc,它们具有不同的类型。src变量的类型是char *c的类型是char

第二行声明了两个变量xy,它们具有不同的类型。变量x的类型是int,而y是一个包含5个int类型元素的数组。

第三行声明了三个数组mno,它们具有不同的维度和元素数量。

如果每个声明都单独放在一行上,这些声明会更容易理解:

char *src;       // src的类型是char *
char c;          // c的类型是char
int x;           // x的类型是int
int y[5];        // y是一个包含5个int类型元素的数组
int m[12];       // m是一个包含12个int类型元素的数组
int n[15][3];    // n是一个包含15个包含3个int类型元素的数组的数组
int o[21];       // o是一个包含21个int类型元素的数组

可读性强且易理解的代码更不容易出现缺陷。

交换值(第一次尝试)

每个对象都有一个存储期,这决定了对象的生命周期,也就是在程序执行期间对象存在、有存储、拥有一个恒定的地址,并保留其最后存储的值的时间段。在对象的生命周期之外,不应引用该对象。

清单2-1中的ab这样的局部变量具有自动存储期,这意味着程序离开定义它们的块之前,它们会持续存在。我们尝试交换这两个变量中存储的值。

清单2-2是我们实现swap函数的第一次尝试:

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

清单2-2:swap函数

swap函数声明了两个参数,ab,用于将值传递给该函数。

在C语言中,形式参数是在函数声明中定义的,它们在函数被调用时获得实际的值。实际参数则是在函数调用时提供的具体值,它们是通过函数调用的表达式传递给函数的。

swap函数内部,我们创建了一个临时变量 t,它的类型是 int,并将它初始化为 a 的值。这个临时变量 t 的作用是在交换 ab 的值时,用来保存 a 的原始值,以确保这个值不会在交换过程中丢失。

现在,您可以编译并测试整个程序,运行生成的可执行文件。

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

这个结果可能会让人感到惊讶。变量a和b分别初始化为21和17。在swap函数中的第一个printf调用显示这两个值已经交换,但在main函数中的第二个printf调用显示原始值保持不变。让我们看看发生了什么。

C语言是一种值传递语言,这意味着当您将参数传递给函数时,该参数的值被复制到函数内部的一个独立变量中以供使用。swap函数将您传递的对象的值分配给相应的参数。当函数内的参数的值发生变化时,调用者内的值不受影响,因为它们是不同的对象。因此,在main函数中的变量ab在第二次调用printf时保持其原始值。该程序的目标是交换这两个对象的值。通过测试程序,我们发现它存在一个错误,或者说缺陷。

交换值(第二次尝试)

要修复这个错误,您可以使用指针来重写swap函数。我们使用间接运算符*来声明指针和对它们进行解引用,如清单2-3所示:

void swap(int *pa, int *pb) {
	int t = *pa;
	*pa = *pb
	*pb = t;
	return;
}

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

在函数声明或定义中使用 * 时,* 作为指针声明符的一部分,表示参数是特定类型的对象或函数的指针。在重写的 swap 函数中,我们指定了两个参数 papb,并将它们都声明为 int 类型的指针。

当您在函数内的表达式中使用一元运算符* 时,* 对指针解引用,获取指针所指向的对象。例如,考虑以下赋值操作:

pa = pb;

这将使用指针pb的值来替换指针pa的值。现在考虑在swap函数中的实际赋值操作:

*pa = *pb;

这将对指针pb进行解引用,读取所引用的值,然后对指针pa进行解引用,并用指针pb引用的值覆盖了指针pa引用的位置上的值。

当您在main函数中调用swap函数时,您还必须在每个变量名前加上一个&字符:

swap(&a, &b);

&是取地址运算符,它生成其操作数的指针。这个改变是必要的,因为swap函数现在接受int类型的对象的指针作为参数,而不仅仅是int类型的值。

清单2-4展示了整个swap程序,重点是在执行此代码期间创建的对象以及它们的值:

#include <stdio.h>

void swap(int *pa, int *pb) {
    int t = *pa; // t: 21
    *pa = *pb;   // pa → a: 17, pb → b: 17
    *pb = t;     // pa → a: 17, pb → b: 21
}

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

清单2-4:模拟引用传递过程

进入主代码块时,变量ab分别被初始化为21和17。随后,代码获取了这些对象的地址,并将它们作为参数传递给swap函数。

swap函数内部,参数papb都被声明为int类型的指针,并包含了从调用函数(在这种情况下是main函数)传递给swap函数的参数的副本。这些地址副本仍然引用完全相同的对象,因此当它们引用的对象的值在swap函数内部交换时,也会访问和交换在main函数中声明的原始对象的内容。

这种方法通过生成对象的地址、按值传递这些地址,然后解引用复制的地址来模拟引用传递,从而让原始对象的内容被访问和交换。

作用域

对象、函数、宏以及其他C语言标识符具有作用域,用于限定它们可以被访问的连续区域。C语言有四种作用域类型:文件作用域(file scope)、块作用域(block scope)、函数原型作用域(function prototype scope)和函数作用域(function scope)。

对象或函数标识符的作用域由其声明位置决定。如果声明位于任何块(block)或参数列表之外,标识符具有文件作用域,这意味着作用域是整个文本文件,它出现在其中以及在此之后包含的任何文件。

如果声明出现在块内或在参数列表中,它具有块作用域,这意味着它所声明的标识符只能在块内访问。清单2-4中的变量a和b的标识符具有块作用域,只能在它们在main函数中定义的代码块内引用。

如果声明出现在函数原型的参数声明列表中(不是函数定义的一部分),则标识符具有函数原型作用域,该作用域在函数声明符的末尾终止。函数作用域是函数定义的开头花括号{和结束花括号}之间的区域。

标签名称是唯一具有函数作用域的标识符类型。标签是由冒号分隔的标识符,并标识函数中可以进行控制转移的语句。第5章涵盖了标签和控制转移。

作用域可以嵌套,包括内部外部作用域。例如,您可以在另一个块作用域内部有一个块作用域,每个块作用域都在文件作用域内定义。内部作用域可以访问外部作用域,但反之则不成立。正如其名称所示,任何内部作用域必须完全包含在包围它的外部作用域内。

如果在内部作用域和外部作用域都声明了相同的标识符,那么在内部作用域中声明的标识符将隐藏外部作用域中的标识符,优先使用内部作用域中的标识符。在这种情况下,引用该标识符将指代内部作用域中的对象;来自外部作用域的对象将被隐藏,不能通过其名称引用。防止这种问题的最简单方法是使用不同的名称。

清单2-5演示了不同的作用域以及内部作用域中声明的标识符如何隐藏外部作用域中声明的标识符。

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

0

清单2-5:作用域

这段代码没有问题,只要注释准确地描述了您的意图。最佳实践是为不同的标识符使用不同的名称,以避免混淆,从而导致错误。对于具有小作用域的标识符,使用像ij这样的短名称是可以的。具有大作用域的标识符应该具有更长、更具描述性的名称,这些名称不太可能在嵌套作用域中被隐藏。一些编译器会警告有关隐藏的标识符。

存储期

对象具有存储期,它决定了它们的生命周期。总共有四种存储期可用:自动、静态、线程和分配的。您已经看到具有自动存储期的对象是在块内或作为函数参数声明的。这些对象的生命周期从它们所在的块开始执行时开始,当块的执行结束时结束。如果块被递归地进入,每次都会创建一个新对象,每个对象都有自己的存储。

作用域和生命周期是完全不同的概念。作用域适用于标识符,而生命周期适用于对象。标识符的作用域是指标识符所代表的对象可以通过其名称访问的代码区域。对象的生命周期是指对象存在的时间段。

在文件作用域内声明的对象具有静态存储期。这些对象的生命周期是整个程序的执行过程,它们的存储值在程序启动之前被初始化。您还可以在块作用域内使用存储类别说明符static来声明一个变量具有静态存储期,就像在清单2-6中的计数示例中所示。这些对象在函数退出后仍然存在。

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

1

清单2-6:计数

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

静态对象必须使用常量值而不是变量进行初始化:

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

2

常量值指的是字面常量(例如1'a'0xFF)、enum枚举成员以及操作符(如alignofsizeof)的结果,而不是带有const限定符的对象。

线程存储期用于并发编程,不在本书的讨论范围内。分配的存储期涉及动态分配的内存,将在第6章中讨论。

对齐

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

字是指处理器硬件或指令集所处理的固定大小的自然数据单元。有些计算机指令允许在非字边界上进行多字节访问,但这可能会导致性能下降。而在某些平台上,无法有效地访问非对齐的内存。对齐要求通常依赖于CPU字大小,通常为16位、32位或64位。这意味着数据的地址应该是字大小的整数倍。

通常情况下,C程序员无需关心对齐要求,因为编译器会为其各种类型选择合适的对齐方式。使用malloc动态分配的内存必须足够对齐以适应所有标准类型,包括数组和结构体。但在极少数情况下,您可能需要覆盖编译器的默认选择,例如,将数据对齐到内存缓存行的边界上,这些缓存行必须从二的幂地址边界开始,或者满足其他特定于系统的要求。传统上,这些要求可以通过链接器命令或通过使用malloc过度分配内存,然后将用户地址向上舍入,或者涉及其他非标准工具的类似操作来满足。

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

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

3

清单2-7:使用 _Alignas 关键词

对齐方式按从弱到强(也称为更严格)的顺序排序。更严格的对齐方式具有较大的对齐值。满足对齐要求的地址也同时满足任何有效的、较弱的对齐要求。

对象类型

本节介绍了C语言中的对象类型。具体来说,我们将涵盖布尔型、字符型和数值型(包括整数类型和浮点数类型)。

布尔型

声明为_Bool的对象只能存储值01。这种布尔类型是在C99中引入的,以下划线开头,以区别于已经声明了自己的名为boolboolean的标识符的现有程序。

以下划线和大写字母或另一个下划线开头的标识符始终是保留的。这个想法是,C标准委员会可以创建新的关键字,比如_Bool,假设你已经避免使用保留的标识符。如果没有避免使用,那么就是你的责任,因为C标准委员会认为这是你没有仔细阅读标准的问题。

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

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

4

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

字符型

C语言定义了三种字符类型:charsigned charunsigned char。每个编译器实现将char定义为与signed charunsigned char具有相同的对齐方式、大小、范围、表示和行为。但需要注意的是,char是一种单独的类型,与另外两种都不兼容。

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

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

数值型

C提供了几种数字类型,可以用来表示整数、枚举值和浮点数。第三章将更详细地介绍其中一些内容,但这里先进行简要介绍。

整数型

有符号整数型可用于表示负数、正数和零。包括signed charshort intintlong intlong long int

除了int本身外,这些类型的声明中可以省略关键字int,因此,您可以使用long long代替long long int来声明类型。

对于每种有符号整数型,都有一个对应的无符号整数型,它使用相同的存储量:unsigned charunsigned short intunsigned intunsigned long intunsigned 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_tintmax_t

第三章详细介绍了整数型。

枚举型

枚举,或者说enum,允许您定义一种类型,它为具有可枚举的一组常量值的情况分配名称(枚举值)。以下是枚举的示例:

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

5

如果您不在第一个枚举值前使用=运算符指定一个值,那么它的枚举常量值将为0,而后续没有使用=的枚举值将在前一个枚举常量的值上加1。因此,在day枚举中,sun的值为0mon的值为1,以此类推。

您还可以为每个枚举值分配特定的值,就像cardinal_points枚举所示。对枚举值使用=可能会产生具有重复值的枚举常量,如果您错误地假定所有值都是唯一的,这可能会成为一个问题。months枚举将第一个枚举值设置为1,然后没有明确分配值的每个后续枚举值将递增1

枚举常量的实际值必须能够表示为int,但其类型由具体实现定义。例如,Visual C++使用signed int,而GCC使用unsigned int

浮点型

C语言支持三种浮点数类型:floatdoublelong double。浮点数运算类似于实数的运算,并且通常被用作实数运算的模型。C语言支持多种浮点数表示,包括大多数系统上的IEEE浮点运算标准(IEEE 754—2008)。浮点数表示的选择取决于具体的实现。第三章详细介绍了浮点数类型。

空型

void类型是一种相当奇特的类型。关键字void(单独使用)表示”不能容纳任何值”。例如,您可以将其用于指示函数不返回值,或者作为函数的唯一参数,以指示该函数不接受任何参数。另一方面,派生类型 void *表示指针可以引用任何对象。我将在本章后面讨论派生类型。

函数类型

函数类型是派生类型。在这种情况下,类型是由返回类型以及参数的数量和类型派生而来的。函数的返回类型不能是数组类型。

当声明一个函数时,您使用函数声明符来指定函数的名称和返回类型。如果声明符包括参数类型列表和定义,那么每个参数的声明都必须包含一个标识符,除非参数列表只有一个void类型的参数,它不需要标识符。

以下是一些函数类型的声明示例:

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

6

首先,我们声明一个没有参数并返回int类型的函数f

接下来,我们声明一个没有指定参数但返回int指针的函数fip

最后,我们声明两个函数gh,它们都返回void并接受两个int类型的参数。

在函数声明中,指定参数是可选的。但是,如果不这样做,有时可能会出现问题。如果你在C++中为fip编写函数声明,它将声明一个不接受任何参数并返回int *的函数。在C中,fip声明了一个接受任意数量参数且返回int *的函数。你不应该在C中使用空参数列表来声明函数。首先,这是语言的一个不推荐的特性,可能在将来被移除。其次,代码可能会移植到C++,因此应该明确列出参数类型,并在没有参数时使用void

带有参数类型列表的函数类型称为函数原型。函数原型告诉编译器函数接受的参数数量和类型。编译器使用这些信息来验证函数定义和对函数的任何调用中使用了正确数量和类型的参数。

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

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

7

返回类型说明符是 int ;函数声明符是 max(int a, int b) ;函数体是 { return a > b ? a : b; } 。函数类型规定不能包含任何类型限定符(参阅本章稍后部分的”类型限定符”)。函数体本身使用条件运算符( ? : ),在第4章中将进一步解释。该表达式说明,如果 a 大于 b ,则返回 a ;否则,返回 b

派生类型

派生类型是从其他类型构建出的类型。这包括指针、数组、类型定义、结构体和联合体,我们将在这里讨论它们。

指针

指针类型是从它所指向的函数或对象类型(称为引用类型)派生出来的。指针提供了对引用类型实体的引用。

以下三个声明分别声明了一个指向int的指针,一个指向char的指针,以及一个指向void的指针:

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

8

前面在本章中,我介绍了取址符&和间接引用符*。您可以使用&操作符来获取对象或函数的地址。例如,如果对象是int类型,那么该操作符的结果将具有指向int的指针类型:

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

9

我们声明变量ip为指向int的指针,并将其赋值为i的地址。您还可以在*操作符的结果上使用&操作符:

char *src;       // src的类型是char *
char c;          // c的类型是char
int x;           // x的类型是int
int y[5];        // y是一个包含5个int类型元素的数组
int m[12];       // m是一个包含12个int类型元素的数组
int n[15][3];    // n是一个包含15个包含3个int类型元素的数组的数组
int o[21];       // o是一个包含21个int类型元素的数组

0

使用*ip进行解引用将解析为实际对象i,之后使用&操作符获取*ip的地址,因此这两个操作相互抵消。

*将类型的指针转换为该类型的值。它表示间接引用,并且仅对指针起作用。

如果操作数指向一个函数,则使用*操作符的结果是函数标识符,如果它指向一个对象,则结果是指定对象的值。例如,如果操作数是指向int的指针,则间接操作符的结果具有int类型。

如果指针没有指向有效的对象或函数,可能会发生不好的事情。

数组

数组是一系列具有相同元素类型的连续分配的对象。数组类型由它们的元素类型和数组中的元素数量特征化。在这里,我们声明了一个由11个int类型元素组成的数组,它被标识为ia,以及一个由17个指向float的指针类型元素组成的数组,它被标识为afp

char *src;       // src的类型是char *
char c;          // c的类型是char
int x;           // x的类型是int
int y[5];        // y是一个包含5个int类型元素的数组
int m[12];       // m是一个包含12个int类型元素的数组
int n[15][3];    // n是一个包含15个包含3个int类型元素的数组的数组
int o[21];       // o是一个包含21个int类型元素的数组

1

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

char *src;       // src的类型是char *
char c;          // c的类型是char
int x;           // x的类型是int
int y[5];        // y是一个包含5个int类型元素的数组
int m[12];       // m是一个包含12个int类型元素的数组
int n[15][3];    // n是一个包含15个包含3个int类型元素的数组的数组
int o[21];       // o是一个包含21个int类型元素的数组

2

第一行声明了一个带有上限为11的char数组。这分配了足够的存储空间来创建一个包含10个字符和一个空字符的字符串。for循环迭代了10次,其中i的值从09。每次迭代都将表达式'0' + i的结果赋值给str[i]。在循环结束后,空字符被复制到数组的最后一个元素。

在表达式str[i] = '0' + i中,str会自动转换为指向数组的第一个成员(char类型的对象)的指针,而i具有无符号整数类型。

下标[]运算符和加法+运算符的定义是,str[i]等同于*(str + i)。当str是数组对象时(正如在这里),表达式str[i]表示数组的第i个元素(从0开始计数)。因为数组的索引从0开始,所以char数组str[11]的索引范围是从010,其中10是最后一个元素,正如这个示例的最后一行所引用的。

如果&运算符操作的值是[]运算符的结果,那么就相当于去除&,将[i]改写为a + i,例如&str[10]str + 10其实是等价的。

您还可以声明多维数组。在示例中,清单2-8在main函数中将arr声明为一个二维的5×3 int数组,也称为矩阵

char *src;       // src的类型是char *
char c;          // c的类型是char
int x;           // x的类型是int
int y[5];        // y是一个包含5个int类型元素的数组
int m[12];       // m是一个包含12个int类型元素的数组
int n[15][3];    // n是一个包含15个包含3个int类型元素的数组的数组
int o[21];       // o是一个包含21个int类型元素的数组

3

程序清单2-8:矩阵运算

更精确地说,arr是一个包含三个元素的数组,每个元素都是一个包含五个int类型元素的数组。当您在示例中使用func(arr[i]),会发生以下情况:

  1. 当使用arr(没有索引)时,它表示整个二维数组。在需要指针的上下文中(比如作为函数参数时),arr被自动转换(或者说”退化”)成指向其第一个元素的指针。这里的第一个元素是一个包含5个int类型元素的数组。因此,arr转换成的是指向第一个包含5个int的数组的指针,类型为int (*)[5]
  2. 当使用arr[i]时,i被”缩放”以匹配arr的类型。具体来说,因为arr的元素是一个包含5个int的数组,所以i会被乘以这样一个数组的大小(即i乘以sizeof(int[5]))。这样做是为了计算出从arr开始到第i个包含5个int的数组的字节偏移量。
  3. 第1步和第2步的结果相加,实际上就是计算出arr+i的内存地址。由于arr已经被转换成了指向第一个数组的指针,加上通过i计算出的偏移量,这个操作定位到了第i个包含5个int的数组的起始地址。
  4. 对第3步的结果应用间接引用操作。但是,在C语言中,对数组类型的指针应用间接引用操作时,结果仍然是一个数组类型。在这种情况下,*(arr+i)arr[i]的结果是第i个包含5个int元素的数组。按照C语言的规则,这个数组再次被转换成指向其第一个元素的指针,以便作为func的参数传递。

在表达式arr[i][j]中使用时,该数组被转换为指向int类型的第一个元素的指针,因此arr[i][j]产生一个int类型的对象。

类型定义

使用typedef关键字来声明现有类型的别名,它并不会创建新的类型。例如,以下每个声明都创建了一个新的类型别名:

char *src;       // src的类型是char *
char c;          // c的类型是char
int x;           // x的类型是int
int y[5];        // y是一个包含5个int类型元素的数组
int m[12];       // m是一个包含12个int类型元素的数组
int n[15][3];    // n是一个包含15个包含3个int类型元素的数组的数组
int o[21];       // o是一个包含21个int类型元素的数组

4

在第一行中,我们声明uint_typeunsigned int类型的别名。

在第二行中,我们声明schar_typesigned char的别名,schar_psigned char *的别名,fpsigned char(*) (void)的别名。

标准头文件中以_t结尾的标识符是类型定义(现有类型的别名)。一般来说,您不应该在自己的代码中遵循这种约定,因为C标准保留了与模式int[0-9a-z_]*_tuint[0-9a-z_]*_t匹配的标识符,并且便携式操作系统接口(POSIX)保留了所有以_t结尾的标识符。如果您定义了使用这些名称的标识符,它们可能会与实现中使用的名称冲突,这可能会导致难以调试的问题。

结构体

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

char *src;       // src的类型是char *
char c;          // c的类型是char
int x;           // x的类型是int
int y[5];        // y是一个包含5个int类型元素的数组
int m[12];       // m是一个包含12个int类型元素的数组
int n[15][3];    // n是一个包含15个包含3个int类型元素的数组的数组
int o[21];       // o是一个包含21个int类型元素的数组

5

清单2-9:结构体

结构体 sigrecord 包含了三个成员对象:signum 是一个 int 类型的对象,signame 是一个包含 20 个 char 元素的数组,sigdesc 是一个包含 100 个 char 元素的数组。结构体用于组织多个相关的数据成员,通常用来表示一些复杂的数据结构,如日期、客户信息或人员记录等。

要访问结构体的成员对象,你可以使用成员访问操作符 .,如果有一个指向结构体的指针,则可以使用结构体指针访问操作符 -> 来引用其成员。以下是示例代码,演示了这两种操作符的使用。

char *src;       // src的类型是char *
char c;          // c的类型是char
int x;           // x的类型是int
int y[5];        // y是一个包含5个int类型元素的数组
int m[12];       // m是一个包含12个int类型元素的数组
int n[15][3];    // n是一个包含15个包含3个int类型元素的数组的数组
int o[21];       // o是一个包含21个int类型元素的数组

6

清单2-10:引用结构体成员

清单 2-10 中的前三行代码通过使用 . 运算符直接访问了 sigline 对象的成员。在sigline_p = &sigline处,我们将指向 sigline 对象的指针分配给了 sigline_p 的地址。在程序的最后三行中,我们通过使用 -> 运算符通过 sigline_p 指针间接访问了 sigline 对象的成员。

联合体

联合类型与结构体类似,但有一个关键区别:联合体的成员共享同一块内存空间,但在不同时间只能包含一个成员。这使得联合体非常适合在不同情况下存储不同类型的数据,从而节省内存。

下面是一个包含三个结构体的联合示例:这个联合可以用于表示一些节点,这些节点可能包含不同类型的数据。有些节点包含整数值,而其他节点包含浮点数值。

char *src;       // src的类型是char *
char c;          // c的类型是char
int x;           // x的类型是int
int y[5];        // y是一个包含5个int类型元素的数组
int m[12];       // m是一个包含12个int类型元素的数组
int n[15][3];    // n是一个包含15个包含3个int类型元素的数组的数组
int o[21];       // o是一个包含21个int类型元素的数组

7

清单2-11:联合体

要访问联i体合的成员,您可以使用.操作符。如果有一个指向联合体的指针,可以使用->操作符来引用其成员。在示例中,通过u.nf.type可以访问联合体的类型成员,通过u.nf.doublenode可以访问浮点数值成员。使用联合体,可以在不浪费额外内存的情况下存储不同类型的数据。

标签

标签(tag)是用于结构体(struct)、联合体(union)和枚举(enum)的特殊命名机制。例如,下面的结构体中的标识符 s 就是一个标签:

char *src;       // src的类型是char *
char c;          // c的类型是char
int x;           // x的类型是int
int y[5];        // y是一个包含5个int类型元素的数组
int m[12];       // m是一个包含12个int类型元素的数组
int n[15][3];    // n是一个包含15个包含3个int类型元素的数组的数组
int o[21];       // o是一个包含21个int类型元素的数组

8

单独的标签不是类型名称,不能用于声明变量。您必须按照以下方式声明此类型的变量:

char *src;       // src的类型是char *
char c;          // c的类型是char
int x;           // x的类型是int
int y[5];        // y是一个包含5个int类型元素的数组
int m[12];       // m是一个包含12个int类型元素的数组
int n[15][3];    // n是一个包含15个包含3个int类型元素的数组的数组
int o[21];       // o是一个包含21个int类型元素的数组

9

联合体和枚举的名称也是标签,而不是类型,这意味着它们不能单独用于声明变量。例如:

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

0

结构体、联合体和枚举的标签在与普通标识符不同的命名空间中定义。这使得 C 程序可以在同一作用域内拥有与标签相同拼写的另一个标识符:

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

1

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

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

2

虽然这不是一个良好的实践,但在 C 中是有效的。您可以将结构体标签视为类型名称,并使用 typedef 来为标签定义别名。以下是一个示例:

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

3

在此示例中,t 现在是 struct s 的别名,您可以使用 t 来声明变量,就像使用原始的结构体标签一样。

现在,您可以声明类型为 t 的变量,而不是 struct s。在结构体、联合体和枚举中,标签名称是可选的,因此您可以完全不使用它,像这样:

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

4

这个做法在大多数情况下都可以正常工作,但对于包含指向自身的指针的自引用结构体,可能会导致问题,例如:

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

5

如果省略了第一行的标签,编译器可能会抱怨,因为在第 3 和第 4 行引用的结构体尚未声明,或者因为整个结构体在任何地方都没有使用。因此,您别无选择,只能为结构体声明一个标签,但也可以同时声明一个 typedef,如下所示:

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

6

大多数 C 程序员使用不同的名称来命名标签和 typedef,但相同的名称也可以正常工作。您还可以在结构体之前定义此类型,以便在声明引用其他类型为 tnode 的对象的 left 和 right 成员时使用它:

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

7

类型定义不仅仅在结构体中使用,还可以提高代码的可读性。例如,以下三个声明 signal 函数的方式都指定了相同的类型:

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

8

这些 typedef 可以使代码更易于理解,因为它们为函数指针类型引入了有意义的别名。

限定符

到目前为止,我们讨论的所有类型都是未经限定的类型。可以通过使用以下一种或多种限定符来对类型进行限定constvolatilerestrict。每个限定符都会在访问具有该限定类型的对象时改变行为。

限定版本和未限定版本的类型可以在函数的参数、函数的返回值和联合的成员之间互换使用。这意味着可以将具有限定符的对象传递给接受未限定类型的函数,反之亦然。这种互换性可以在编程中提供灵活性和可维护性。

自C11起,_Atomic类型限定符支持并发程序。

const

const限定符用于声明不可修改的对象。具有const限定符的对象不能被修改,特别是不能被赋值,但可以具有常量初始值。这意味着具有const限定符的对象可以由编译器放置在只读内存中,并且任何尝试写入它们的操作都将导致运行时错误。

例如,下面的代码声明了一个具有const限定符的整数对象i,然后试图修改它的值:

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

9

在某些情况下,你可能会误使编译器为你更改const限定的对象。在下面的示例中,我们取得了一个const限定对象i的地址,并告诉编译器这实际上是一个int指针:

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

0

C语言不允许你取消const限定符,如果原始对象被声明为const限定的对象。尽管上述代码可能看起来可以工作,但它是有缺陷的,并且可能在后续引发问题。例如,编译器可能将具有const限定符的对象放置在只读内存中,导致在运行时尝试存储值时出现内存错误。

C语言允许你通过取消const限定符的方式修改被const限定指针指向的对象,前提是原始对象未被声明为const

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

1

在上面的示例中,通过将const限定符的指针转换为int指针,我们可以修改ip指向的对象,但不能修改jp指向的对象,因为jconst限定的。请注意,这种做法并不安全,可能导致未定义的行为和程序错误。

volatile

volatile限定符用于表示特殊用途的对象。具有static volatile限定的对象用于模拟内存映射的输入/输出(I/O)端口,而具有static constant volatile限定的对象则模拟内存映射的输入端口,例如实时时钟。

这些对象中存储的值可能在编译器不知情的情况下发生变化。例如,每次读取实时时钟的值时,即使该值尚未被C程序写入,它也可能发生变化。使用volatile限定类型让编译器知道该值可能会变化,并确保每次访问实时时钟时都会发生(否则,对实时时钟的访问可能会被优化掉,或者被以前读取和缓存的值替代)。例如,在以下代码中,编译器必须生成指令来读取port的值,然后将此值写回port

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

2

如果没有volatile限定符,编译器会将其视为无操作(不执行任何操作的编程语句),并有可能消除读取和写入操作。

此外,volatile限定类型还用于与信号处理程序和setjmp/longjmp进行通信(有关信号处理程序和setjmp/longjmp的信息,请参阅C标准)。与Java和其他编程语言不同,在C中,volatile限定类型不应用于线程之间的同步。

restrict

restrict 限定符用于优化代码,它告诉编译器,通过该指针访问的对象不会同时通过其他指针访问,从而提供了更大的优化潜力。

以下是一个函数示例,该函数从由 q 引用的存储区复制 n 个字节到由 p 引用的存储区。函数参数 pq 都是带有 restrict 限定符的指针:

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

3

因为 pq 都是带有 restrict 限定符的指针,编译器可以假设通过其中一个指针参数访问的对象不会同时通过另一个指针参数访问。编译器可以基于参数声明而不必分析函数体就能做出这个判断。虽然使用 restrict 限定符的指针可以生成更高效的代码,但必须确保这些指针不引用重叠的内存,以避免出现未定义行为。

练习

尝试编写以下代码:

  1. 添加一个检索函数到清单2-6中的计数示例中,以检索当前计数器的值。
  2. 声明一个包含三个函数指针的数组,并根据传入的索引值调用适当的函数。

小结

在本章中,您了解了关于对象和函数的知识,以及它们之间的区别。您学习了如何声明变量和函数,如何获取对象的地址以及如何取消引用对象指针。您还了解了大多数可用于C程序员的对象类型以及派生类型。

在以后的章节中,我们将再次回到这些类型,更详细地探讨如何最好地使用它们来实现您的设计。在下一章中,我将提供关于两种算术类型的详细信息:整数和浮点数。


Share this note on:

Previous note
How_Computers_Really_Work_02_二进制应用
Next note
EffectiveC_00_引言