16.5. 数组和字符串

数组 是一种 C 语言结构,它创建由相同类型数据元素组成的有序集合,并将该集合与单个程序变量关联。有序 意味着每个元素在值集合中都处于特定位置(即,在位置 0、位置 1 等处都有一个元素),而不一定表示值已排序。数组是 C 语言用于对多个数据值进行分组并通过单个名称引用它们的主要机制之一。数组有几种类型,但基本形式是_一维数组_,这对于在 C 语言中实现类似列表的数据结构和字符串很有用。C 数组与 Java 的 Array 类最为相似。

16.5.1. 数组介绍

C 数组可以存储多个相同类型的数据值。在本章中,我们讨论静态声明的数组,这意味着总容量(数组中可以存储的最大元素数)是固定的,并且在声明数组变量时定义。在第 2 章中,我们讨论动态分配的数组多维数组

表 1 显示了 Java 和 C 版本的程序,该程序初始化然后打印一组整数值。Java 和 C 版本都使用 int 类型的数组来存储值集合。

一般来说,Java 为程序员提供了高级接口,隐藏了许多低级实现细节。另一方面,C 向程序员公开了低级数组实现,并将高级功能留给程序员实现。换句话说,数组支持低级数据存储,而没有高级列表功能,例如长度比较二进制搜索等。Java 还在其ListArrayList类中​​提供了几个高级列表抽象,它们都支持动态调整值列表的大小。相比之下,C 程序员则需要在固定大小的数组之上实现这些类型的抽象。

表 1. Java 和 C 中数组的语法比较

Java versionC version
/* Example Java program using an Array */

class ArrayExample {

public static void main(String[] args) {

int i, size = 0;

// create and init array of 3 ints
int[] small_arr = {1, 3, 5};

// declare and create array of 10 ints
int[] nums = new int[10];

// set value of each element
for (i = 0; i < 10; i++) {
nums[i] = i;
size++;
}

// set value at position 3 to 5
nums[3] = small_arr[2];

// print number of array elements
System.out.printf("array size: %d\n",
size); // or nums.length

// print each element of nums
for (i = 0; i < 10; i++) {
System.out.printf("%d\n", nums[i]);
}

}
}
/* Example C program using arrays */

#include <stdio.h>

int main(void) {

int i, size = 0;

// declare and init array of 3 ints
int small_arr[] = {1, 3, 5};

// declare array of 10 ints
int nums[10];

// set value of each element
for (i = 0; i < 10; i++) {
nums[i] = i;
size++;
}

// set value at position 3 to 5
nums[3] = small_arr[2];

// print number of array elements
printf("array size: %d\n",
size);

// print each element of nums
for (i = 0; i < 10; i++) {
printf("%d\n", nums[i]);
}

return 0;
}

该程序的 C 和 Java 版本几乎完全相同。具体来说,可以通过 索引 访问各个元素,并且索引值从 0 开始。也就是说,两种语言都将集合中的第一个元素称为位置 0 处的元素。

在 C 和 Java 中,数组都是固定容量的数据结构(相对于随着添加更多元素而容量增加的数据结构)。此程序的 C 和 Java 版本的主要区别在于数组类型的声明方式以及如何分配其容量空间。

在 Java 中,数组类型的语法是 <typename>[],并且使用 new <typename>[<capacity>] 为一定容量的数组分配空间。例如:

对于 Java 数组:

int[] nums;          // declare nums as an array of int
nums = new int[10];  // create a new int array of capacity 10

在 C 语言中,数组类型使用 <typename> <varname>[<capacity>] 声明。例如:

对于 C 数组:

int nums[10];    // declare nums as an array of capacity 10

在 C 语言中声明数组变量时,程序员必须在定义中指定其类型(数组中存储的每个值的类型)及其总容量(最大存储位置数)。例如:

int  arr[10];  // declare an array of 10 ints
char str[20];  // declare an array of 20 chars

上述声明创建了一个名为arr的变量,一个总容量为 10 的int值数组,以及另一个名为str的变量,一个总容量为 20 的char值数组。

Java 和 C 都允许程序员声明和初始化声明中的元素(两者中的 small_arr 数组都是容量为 3 的数组,用于存储 int 值 135):

// java version:
int[]  small_arr = {1, 3, 5};
// C version:
int  small_arr[] = {1, 3, 5};

由于数组是 Java 中的对象,因此 Array 类中有很多方法可用于与 Java 数组交互,而不仅仅是通过简单的索引来获取和设置值。其中一些方法包括搜索数组和从数组创建其他数据结构的方法。C 对数组的支持仅限于创建相同类型元素的有序集合,并支持索引以访问单个数组元素。对数组的任何高级处理都必须由 C 程序员实现。

Java 和 C 都将数组值存储在连续的内存位置中。C 规定了程序内存中的数组布局,而 Java 向程序员隐藏了其中的一些细节。在 C 中,各个数组元素分配在程序内存中的连续位置。例如,第三个数组位置位于内存中紧接着第二个数组位置之后,紧接着第四个数组位置之前。Java 也是如此,但是 Java 数组中存储的通常是对象引用,而不是对象值本身。因此,尽管连续数组元素的对象引用在程序内存中连续存储,但它们引用的对象可能不是在内存中连续存储的。

16.5.2. 数组访问方法

Java 提供了多种方法来访问其数组中的元素。但是,如前所述,C 仅支持索引。有效索引值的范围是从 0 到数组容量减 1。以下是一些示例:

int i, num;
int arr[10];  // declare an array of ints, with a capacity of 10

num = 6;      // keep track of how many elements of arr are used

// initialize first 5 elements of arr (at indices 0-4)
for (i=0; i < 5; i++) {
    arr[i] = i * 2;
}

arr[5] = 100; // assign the element at index 5 the value 100

此示例声明的数组容量为 10(它有 10 个元素),但只使用了前 6 个(我们当前的值集合大小为 6,而不是 10)。使用静态声明的数组时,通常会出现数组部分容量未使用的情况。因此,我们需要另一个程序变量来跟踪数组中的实际大小(元素数量)(此示例中为 num)。

当程序尝试访问无效索引时,Java 和 C 的错误处理方法有所不同。如果使用无效索引值访问数组中的元素,Java 会抛出 java.lang.ArrayIndexOutOfBoundsException 异常。在 C 中,程序员需要确保他们的代码在索引数组时仅使用有效的索引值。因此,对于像下面这样访问分配数组边界之外的数组元素的代码,程序的运行时行为是未定义的:

int array[10];    // an array of size 10 has valid indices 0 through 9

array[10] = 100;  // 10 is not a valid index into the array

C 编译器很乐意编译访问数组边界之外的数组位置的代码;编译器或运行时不会进行边界检查。因此,运行此代码可能会导致意外的程序行为(并且每次运行的行为可能不同)。它可能会导致程序崩溃,可能会更改另一个变量的值,也可能对程序的行为没有影响。换句话说,这种情况会导致程序错误,可能会也可能不会显示为意外的程序行为。因此,作为 C 程序员,您必须确保您的数组访问引用有效位置!

16.5.3. 数组和函数

在 C 语言中将数组传递给函数的语义与在 Java 语言中将数组传递给函数的语义类似:函数可以改变传递的数组中的元素。下面是一个接受两个参数的示例函数,一个 int 数组参数 (arr)和一个 int 参数 (size):

void print_array(int arr[], int size) {
    int i;
    for (i = 0; i < size; i++) {
        printf("%d\n", arr[i]);
    }
}

参数名称后面的 [] 告诉编译器参数 arr 的类型是int 数组,而不是像参数 size 那样的 int。在第 2 章中,我们展示了指定数组参数的另一种语法。数组参数 arr 的容量未指定:arr[] 表示可以使用任意容量的数组参数调用此函数。因为无法仅从数组变量中获取数组的大小或容量,所以传递数组的函数几乎总是还有第二个参数来指定数组的大小(前面示例中的 size 参数)。

要调用具有数组参数的函数,请将数组名称作为参数传递。下面是一段 C 代码片段,其中包含对 print_array 函数的示例调用:

int some[5], more[10], i;

for (i = 0; i < 5; i++) {  // initialize the first 5 elements of both arrays
    some[i] = i * i;
    more[i] = some[i];
}

for (i = 5; i < 10; i++) { // initialize the last 5 elements of "more" array
    more[i] = more[i-1] + more[i-2];
}

print_array(some, 5);    // prints all 5 values of "some"
print_array(more, 10);   // prints all 10 values of "more"
print_array(more, 8);    // prints just the first 8 values of "more"

在 C 语言中,数组变量的名称相当于数组的基地址(即其第 0 个元素的内存位置)。由于 C 的 按值传递 函数调用语义,当你将数组传递给函数时,数组的每个元素 不会 单独传递给函数。换句话说,函数不会收到每个数组元素的副本。相反,数组参数会获取 数组基地址的值 。此行为意味着当函数修改作为参数传递的数组的元素时,这些更改将在函数返回时保留。例如,考虑以下 C 程序片段:

void test(int a[], int size) {
    if (size > 3) {
        a[3] = 8;
    }
    size = 2; // changing parameter does NOT change argument
}

int main(void) {
    int arr[5], n = 5, i;

    for (i = 0; i < n; i++) {
        arr[i] = i;
    }

    printf("%d %d", arr[3], n);  // prints: 3 5

    test(arr, n);
    printf("%d %d", arr[3], n);  // prints: 8 5

    return 0;
}

main 中对 test 函数的调用传递了参数 arr,其值是内存中 arr 数组的基地址。测试函数中的参数 a 获取此基地址值的副本。换句话说,参数 a 与其参数 arr 指向相同的数组存储位置。因此,当测试函数更改存储在 a 数组中的值(a[3] = 8)时,它会影响参数数组中的相应位置(arr[3] 现在为 8)。原因是 a 的值是 arr 的基地址,而 arr 的值是 arr 的基地址,因此 aarr 都引用同一个数组(内存中相同的存储位置)! 图 1 显示了测试函数返回之前执行过程中的堆栈内容。

A stack with two frames: main at the bottom and test on the top. main has two variables, an integer n (5) and an array storing values 0, 1, 2, 8, and 4.  Test also has two values, an integer size (2) and an array parameter arr that stores the base memory address of the array in main’s stack frame.

图 1. 具有数组参数的函数的堆栈内容

参数a传递的是数组参数arr基地址的值,这意味着它们都引用内存中同一组数组存储位置。我们用从aarr的箭头表示这一点。函数test修改的值已突出显示。更改参数size的值不会改变其对应参数n的值,但更改a引用的元素之一的值(例如,a[3] = 8)会影响arr中相应位置的值。

16.5.4. 字符串和 C 字符串库简介

Java 实现了 String 类并提供了使用字符串的丰富接口。C 没有定义字符串类型。相反,字符串被实现为 char 值数组。并非每个字符数组都用作 C 字符串,但每个 C 字符串都是字符数组。

回想一下,C 语言中数组的定义大小可能比程序最终使用的大小要大。例如,我们之前在  “数组访问方法” 一节中看到,我们可能声明一个大小为 10 的数组,但只使用前六个位置。这种行为对字符串有重要影响:我们不能假设字符串的长度等于存储它的数组的长度。因此,C 语言中的字符串必须以特殊字符值(空字符('\0'))结尾,以指示字符串的结尾。

以空字符结尾的字符串被称为以空字符终止。尽管 C 中的所有字符串都应该以空字符终止,但未能正确处理空字符是 C 程序员新手常犯的错误。使用字符串时,请务必记住,字符数组必须声明为具有足够的容量来存储字符串中的每个字符值加上空字符 ('\0')。例如,要存储字符串"hi",您需要一个至少包含三个字符的数组(一个用于存储'h',一个用于存储'i',一个用于存储'\0')。

由于字符串的使用十分普遍,C 语言提供了一个字符串库,其中包含用于操作字符串的函数。使用这些字符串库函数的程序需要包含 string.h 头文件。

C 字符串库提供了一些与 Java String 类类似的功能,用于操作字符串值。但是在 C 中,程序负责确保传递给 C 字符串库的字符串格式正确(以空字符结尾的字符数组),并且传递的字符数组具有足够的容量供库函数使用。Java 向程序员隐藏了这些细节,因此程序员在 Java 程序中使用字符串时无需考虑这些细节。

使用 printf 打印字符串的值时,请在格式字符串中使用 %s 占位符。printf 函数将打印数组参数中的所有字符,直到遇到 '\0' 字符。同样,字符串库函数通常通过搜索 '\0' 字符来找到字符串的末尾,或者在它们修改的任何字符串的末尾添加 '\0' 字符。

这是一个使用字符串和字符串库函数的示例程序:

#include <stdio.h>
#include <string.h>   // include the C string library

int main(void) {
    char str1[10];
    char str2[10];
    int len;

    str1[0] = 'h';
    str1[1] = 'i';
    str1[2] = '\0';

    len = strlen(str1);

    printf("%s %d\n", str1, len);  // prints: hi 2

    strcpy(str2, str1);     // copies the contents of str1 to str2
    printf("%s\n", str2);   // prints:  hi

    strcpy(str2, "hello");  // copy the string "hello" to str2
    len = strlen(str2);
    printf("%s has %d chars\n", str2, len);   // prints: hello has 5 chars
}

C 字符串库中的 strlen 函数返回其字符串参数中的字符数。字符串的终止空字符不计入字符串长度,因此对 strlen(str1) 的调用返回 2(字符串 "hi" 的长度)。 strcpy 函数每次将一个字符从源字符串(第二个参数)复制到目标字符串(第一个参数),直到到达源中的空字符。

请注意,大多数 C 字符串库函数都要求调用时传入一个字符数组,该数组具有足够的容量供函数执行其任务。例如,您不会希望使用大小不足以包含源字符串的目标字符串来调用strcpy;这样做会导致程序中出现未定义的行为!

C 字符串库函数还要求传递给它们的字符串值格式正确,并以 '\0' 结尾。作为 C 程序员,您必须确保传入的字符串对 C 库函数有效。因此,在上面的示例中对 strcpy 的调用中,如果源字符串(str1)未初始化为具有终止 '\0' 字符,则 strcpy 将继续超出 str1 数组边界的末尾,从而导致未定义的行为,并可能导致其崩溃。

[!NOTE] 前面的示例安全地使用了strcpy函数。但一般来说,strcpy会带来安全风险,因为它假定其目标足够大,可以存储整个字符串,但情况可能并非总是如此(例如,如果字符串来自用户输入)。

我们现在选择展示strcpy以简化对字符串的介绍,但我们在第 2.6 节中说明了更安全的替代方案。

在第 2 章中,我们更详细地讨论了2.6. 字符串和字符串库