Skip to content

Latest commit

 

History

History
614 lines (370 loc) · 23.9 KB

chapter-8.md

File metadata and controls

614 lines (370 loc) · 23.9 KB

8. 数组的基本概念

8.1 数组的基本概念

定义一个数组类型变量

  • 数组由一系列相同类型的元素组成

    数组(Array)也是一种复合数据类型,它由一系列相同类型的元素(Element)组成。

  • 定义一个数组类型的变量

    例如定义一个由4个int型元素组成的数组count:

    int count[4];
  • 数组元素的存储空间也是相邻的

    和结构体成员类似,数组count的4个元素的存储空间也是相邻的。

  • 结构体类型和数组类型嵌套

    结构体成员可以是基本数据类型,也可以是复合数据类型,数组中的元素也是如此。

    根据组合规则,我们可以定义一个由4个结构体元素组成的数组:

    struct complex_struct {
        double x, y;
    } a[4];

    也可以定义一个包含数组成员的结构体:

    struct {
        double x, y;
        int count[4];
    } s;
  • 定义数组类型变量时数组的长度

    数组类型的长度应该用一个整数常量表达式来指定。

    C99的新特性允许在数组长度表达式中使用变量,称为变长数组(Variable Length Array,VLA),VLA只能定义为局部变量而不能是全局变量,与VLA有关的语法规则比较复杂,本书不做详细介绍。

访问数组元素

  • 数组中的元素通过下标访问

    数组中的元素通过下标(或者叫索引,Index)来访问。

    例如前面定义的由4个int型元素组成的数组count。整个数组占了4个int型的存储单元,存储单元用小方框表示,里面的数字是存储在这个单元中的数据(假设都是0),而框外面的数字是下标,这4个单元分别用count[0]、count[1]、count[2]、count[3]来访问。

    • 访问数组元素时,第一个元素用 0 做下标

      注意,在定义数组int count[4];时,方括号(Bracket)中的数字4表示数组的长度,而在访问数组时,方括号中的数字表示访问数组的第几个元素。

      和我们平常数数不同,数组元素是从“第0个”开始数的,大多数编程语言都是这么规定的,所以计算机术语中有Zeroth这个词。

      这样规定使得访问数组元素非常方便,比如count数组中的每个元素占4个字节,则count[0]位于数组开头,而count[i]表示从数组开头跳过4×i个字节之后的那个存储单元。

  • 数组取下标表达式可以做左值

    这种数组下标的表达式不仅可以表示存储单元中的值,也可以表示存储单元本身,也就是说可以做左值,因此以下语句都是正确的:

    count[0] = 7;
    count[1] = count[0] * 2;
    ++count[2];
    • ++count[2]怎么算:后缀运算符和单目运算符

      到目前为止我们学习了五种后缀运算符:后缀++、后缀--、结构体取成员.、数组取下标[]、函数调用()。

      还学习了五种单目运算符(或者叫前缀运算符):前缀++、前缀--、正号+、负号-、逻辑非!。

      在C语言中后缀运算符的优先级最高,单目运算符的优先级仅次于后缀运算符,比其他运算符的优先级都高,所以上面举例的++count[2]应该看作对count[2]做前缀++运算。

  • 数组下标除了可以是整数还可以是整型表达式

    数组下标也可以是表达式,但表达式的值必须是整型的。例如:

    int i = 10;
    count[i] = count[i + 1];
  • 使用数组下标注意是否超出数组长度范围,编译器不检查这种错误

    使用数组下标不能超出数组的长度范围,这一点在使用变量做数组下标时尤其要注意。

    • 访问越界错误属于运行时错误

      C编译器并不检查count[-1]或是count[100]这样的访问越界错误,编译时能顺利通过,所以属于运行时错误

    • 为什么编译器不检查这种错误

      你可能会想为什么编译器对这么明显的错误都视而不见?

      理由一,这种错误并不总是显而易见的,在第22章会讲到通过指针而不是数组名来访问数组的情况,指针指向数组中的什么位置只有运行时才知道,编译时无法检查是否越界,而运行时每次访问数组元素都检查越界会严重影响性能,所以干脆不检查了;

      理由二,参考文献[6]的第0章指出,C语言的设计精神是:相信每个C程序员都是高手,不要阻止程序员去干他们需要干的事,高手们使用count[-1]这种技巧其实并不少见,不应该当做错误。

    • 这种错误非常隐蔽,写代码的时候就要小心

      但有时候这种错误很隐蔽,发生访问越界时程序可能并不会立即崩溃,而执行到后面某个正确的语句时却有可能突然崩溃(在第10.4节我们会看到这样的例子)。

      所以从一开始写代码时就要小心避免出问题,事后依靠调试来解决问题的成本是很高的。

初始化数组类型变量

  • 指定数组长度进行初始化

    数组也可以像结构体一样初始化,未赋初值的元素也是用0来初始化,例如:

    int count[4] = { 3, 2, };

    则count[0]等于3,count[1]等于2,后面两个元素等于0。

  • 不置顶数组长度进行初始化

    如果定义数组的同时初始化它,也可以不指定数组的长度,例如:

    int count[] = { 3, 2, 1, };

    编译器会根据Initializer有三个元素确定数组的长度为3。

  • Memberwise initialization

    利用C99的新特性也可以做Memberwise Initialization:

    int count[4] = { [2] = 3 };

数组不能相互赋值或初始化

  • 数组不能相互赋值或初始化

    数组和结构体虽然有很多相似之处,但也有一个显著的不同:数组不能相互赋值或初始化。

    比如:

    int a[5] = { 4, 3, 2, 1 };
    int b[5] = a;

    用数组a来初始化数组b是错的。

    再比如:

    a = b;

    用数组b给数组a赋值也是错的。

  • 函数参数可以是数组类型,返回类型不能是数组类型

    既然不能相互赋值,也就不能用数组类型作为函数的参数或返回值。

    如果写出这样的函数定义:

    void foo(int a[5])
    {
        ...
    }

    然后这样调用:

    int array[5] = {0};
    foo(array);

    编译器也不会报错,但这样写并不是传一个数组类型参数的意思。

    • 数组类型做右值时,自动转换成指针

      对于数组类型有一条特殊的类型转换规则:数组类型做右值使用时,自动转换成指向数组首元素的指针。

    • 如果在函数原型中写一个数组类型的参数,该参数实际上是指针类型

      对于函数声明也有一条特殊规则:在函数原型中,如果参数写成数组的形式,则该参数实际上是指针类型。

    • 函数的实参形参是数组类型时,实际上传递的是指针类型

      所以上面的函数调用其实是传一个指针类型的参数,而不是数组类型的参数。

    接下来的几章里有的函数需要访问数组,我们就把数组定义为全局变量给函数访问,等到第22章讲了指针再使用传参的办法。

  • 数组不能相互赋值或初始化的原因

    数组类型不能相互赋值或初始化也是因为这条规则,例如上面提到的a=b这个表达式,a和b都是数组类型的变量,但是b做右值使用,自动转换成指针类型,而左边仍然是数组类型,所以编译器报的错是error: incompatible types in assignment。

8.2 数组应用实例:统计随机数

随机数

  • 计算机生成完全随机的数不容易

    随机数在某些场合(例如游戏程序)是非常有用的,但是用计算机生成完全随机的数却不是那么容易。

    计算机执行每一条指令的结果都是确定的,没有一条指令产生的是随机数。

  • C 标准库提供伪随机数,不是真的随机数

    调用C标准库函数得到的随机数其实是伪随机(Pseudorandom)数,是用数学公式算出来的确定的数,只不过这些数看起来很随机,并且从统计意义上也很接近于均匀分布(Uniform Distribution)的随机数。

  • C 标准库中 rand 函数生成伪随机数

    C标准库中生成伪随机数的是rand函数,使用这个函数需要包含头文件stdlib.h,它没有参数,返回值是一个介于0和RAND_MAX之间的接近均匀分布的整数。

    RAND_MAX是该头文件中定义的一个常量,在不同的平台上有不同的取值,但可以肯定它是一个非常大的整数。

    • 生成一个特定范围内的随机数

      通常我们用到的随机数是限定在某个范围之中的,例如0~9,而不是0~RAND_MAX,我们可以用%运算符将rand函数的返回值处理一下:

      int x = rand() % 10;

生成并打印随机数

完整的程序如下:

#include <stdio.h>
#include <stdlib.h>
#define N 20

int a[N];

void gen_random(int upper_bound)
{
    int i;
    for (i = 0; i < N; i++)
        a[i] = rand() % upper_bound;
}

void print_random()
{
    int i;
    for (i = 0; i < N; i++)
        printf("%d ", a[i]);
    printf("\n");
}

int main(void)
{
    gen_random(10);
    print_random();
    return 0;
}

预处理指令

这里介绍一种新的语法:用#define定义一个常量。

  • 编译器工作:预处理阶段、编译阶段

    实际上编译器的工作分为两个阶段,先是预处理(Preprocess)阶段,然后才是编译阶段。

  • gcc -E 选项可以看到预处理之后的程序

    用gcc的-E选项可以看到预处理之后、编译之前的程序,例如:

    $ gcc -E main.c
    ...
    int a[20];
    
    void gen_random(int upper_bound)
    {
        int i;
        for (i = 0; i < 20; i++)
            a[i] = rand() % upper_bound;
    }
    
    void print_random()
    {
        int i;
        for (i = 0; i < 20; i++)
            printf("%d ", a[i]);
        printf("\n");
    }
    
    int main(void)
    {
        gen_random(10);
        print_random();
        return 0;
    }
  • 预处理器做的工作

    可见在这里预处理器做了两件事情,一是把头文件stdio.h和stdlib.h在代码中展开,二是把#define定义的标识符N替换成它的定义20(在代码中做了三处替换,分别位于数组的定义中和两个函数中)。

  • 预处理指令

    像#include和#define这种以#号开头的行称为预处理指示(Preprocessing Directive),我们将在第20章学习其他预处理指示。

  • cpp main.c 也有一样效果

    此外,用cpp main.c命令也可以达到同样的效果,只做预处理而不编译,cpp表示C preprocessor。

  • #define 除了可以定义常量还可以定义宏,处理阶段和枚举常量不一样

    那么用#define定义的常量和第7.3节讲的枚举常量有什么区别呢?首先,define不仅用于定义常量,也可以定义更复杂的语法结构,称为宏(Macro)定义。

    其次,define定义是在预处理阶段处理的,而枚举是在编译阶段处理的。

  • include 和 define 不是关键字

    注意,虽然include和define在预处理指示中有特殊含义,但它们并不是C语言的关键字,换句话说,它们也可以用作标识符,例如声明int include;或者void define(int);。

    • 预处理器只认#

      在预处理阶段,如果一行以#号开头,后面跟include或define,预处理器就认为这是一条预处理指示,除此之外出现在其他地方的include或define预处理器并不关心,只当成普通的标识符交给编译阶段去处理。

硬编码

我们只要把#define N的值改为100000,就相当于把整个程序中所有用到N的地方都改为100000了。

如果我们不这么写,而是在定义数组时直接写成int a[20];,在每个循环中也直接使用20这个值,这称为硬编码(Hard coding)。

如果原来的代码是硬编码的,那么一旦需要把20改成100000就非常麻烦,你需要找遍整个代码,判断哪些20表示这个数组的长度就改为100000,哪些20表示别的数量则不做改动,如果代码很长,这是很容易出错的。

所以,写代码时应尽可能避免硬编码,这其实也是一个“提取公因式”的过程,和第7.2节讲的抽象具有相同的作用,就是避免一个地方的改动波及较大的范围。

8.3 数组应用实例:直方图

  • 伪随机数是基于某个初值算出来的,初值相同整个数列相同

    这正说明了这些数是伪随机数,是用一套确定的公式基于某个初值算出来的,只要初值相同,随后的整个数列就都相同。

  • 我们可以 srand 自己指定一个初值,每次就能生成不一样的随机数列

    实际应用中不可能使用每次都一样的随机数,例如开发一个麻将游戏,每次运行这个游戏摸到的牌不应该是一样的。

    因此,C标准库允许我们自己指定一个初值,然后在此基础上生成伪随机数,这个初值称为Seed,可以用srand函数指定Seed。

  • 通常用 time 函数的值作初值

    通常我们通过别的途径得到一个不确定的数作为Seed,例如调用time函数得到当前系统时间距1970年1月1日00:00:00(各种派生自UNIX的系统都把这个时刻称为Epoch,因为UNIX系统最早发明于1969年。)的秒数,然后传给srand:

    srand(time(NULL));

    然后再调用rand,得到的随机数就和刚才完全不同了。

    调用time函数需要包含头文件time.h,这里的NULL表示空指针,到第22.1节再详细解释。

8.4 字符串

字符串字面值是 \0 结尾的字符型数组

  • 字符串字面值是字符型的数组

    字符串字面值和数组类型相似,它的每个元素是字符型的。

  • 每个字符串字面值末尾有 \0

    注意每个字符串末尾都有一个字符'\0'做结束符,这里的\0是ASCII码的八进制表示,也就是ASCII码为0的Null字符,所以字符串也称为“以Null结尾的字符串”(Null-terminated String)。

字符串字面值和下标

  • 可以用下标访问字符串中的字符

    数组元素可以通过数组名加下标的方式访问,而字符串字面值也可以像数组名一样使用,可以加下标访问其中的字符,例如:

    char c = "Hello, world.\n"[14];

    把"Hello, world.\n"这个字符串看作一个数组,从图8.2可以看出,下标14的位置是字符'\0',所以这个语句把'\0'赋给变量c。

  • 不能通过下标修改字符串中的字符

    注意,通过下标可以读取字符串字面值中的字符,却不允许修改其中的字符:

    "Hello, world.\n"[0] = 'A';

    这行代码会产生编译错误“error: assignment of read-only location”,即字符串字面值所代表的存储空间是只读的,不允许修改。

字符串做右值自动转换成指针

字符串字面值还有一点和数组类型相似,做右值使用时自动转换成指向首元素的指针,在第3.3节我们看到printf原型的第一个参数是指针类型,而printf("hello world")其实就是传一个指针参数给printf,关于字符串字面值和指针的关系将在第22.4节详细解释。

相邻的字符串字面值会被自动串接 9.1

printf("This is such a long sentence that "
       "it cannot be held within a line\n");

C编译器会自动把相邻的多个字符串接在一起,以上两个字符串相当于一个字符串"This is such a long sentence that it cannot be held within a line\n"。

注意这个语法有时候会带来一点麻烦,比如下面这段代码在语法上没有问题,但在语义上有问题,你能看出是什么问题吗?

char days[8][20] = { "", "Monday", "Tuesday"
                     "Wednesday", "Thursday", "Friday"
                     "Saturday", "Sunday" };

字符数组可以用字面串字面值初始化

  • 字符数组可以用一个字符串字面值来初始化

    字符串字面值有一种特殊用法,前面讲过数组可以像结构体一样初始化,如果是字符数组,也可以用一个字符串字面值来初始化:

    char str[10] = "Hello";

    相当于:

    char str[10] = { 'H', 'e', 'l', 'l', 'o', '\0' };
    • 字符串不能赋值给字符数组

      这个语法的特殊之处在于,在这里字符串字面值和数组的用法并不相似,我们不能用一个数组给另一个数组初始化,却可以用一个字符串字面值给一个数组初始化。

      另一方面,我们不能把一个数组赋值给另一个数组,同样也不能把一个字符串字面值赋值给一个数组。这些特殊规定没什么道理可讲,一切都可归结于历史原因。

  • 字符串字面值太短了

    str的后4个元素没有指定,自动初始化为'\0',即Null字符。

  • 字符串字面值为 "" 10.2

    用空字符串""初始化一个字符数组,相当于所有元素用'\0'初始化。

  • 只要是 Null 结尾的都叫字符串

    注意,虽然字符串字面值"Hello"是只读的,但用它初始化的数组str却是可读可写的。数组str中保存了一串字符,以Null字符结尾,也可以叫字符串。在本书中只要是以Null结尾的一串字符都叫字符串,不管是像str这样的字符数组,还是像"Hello"这样的字符串字面值。

  • 字符串字面值太长了

    如果用于初始化的字符串字面值比数组还长,比如:

    char str[10] = "Hello, world.\n"

    则数组str只包含字符串的前10个字符,不包含Null字符,这种情况编译器会给出警告。

  • 用字符串初始化字符数组时最好不指定数组长度

    如果要用一个字符串字面值准确地初始化一个字符数组,最好的办法是不指定数组的长度,让编译器自己计算:

    char str[] = "Hello, world.\n";

    字符串字面值的长度包括Null字符在内一共15个字符,编译器会确定数组str的长度为15。

  • 字符串比数组长 1

    有一种情况需要特别注意,如果用于初始化的字符串字面值比数组刚好长出一个Null字符的长度,比如:

    char str[14] = "Hello, world.\n";

    则数组str不包含Null字符,并且编译器不会给出警告,参考文献[6]的6.7.8节说这样规定是为程序员方便,以前的很多编译器都是这样实现的,不管它有理没理,C标准既然这么规定了我们也没办法,只能自己小心了。

字符数组打印

补充一点,printf函数的格式化字符串中可以用%s表示字符串的占位符。

在学习字符数组以前,我们用%s没什么意义,因为:

printf("string: %s\n", "Hello");

还不如写成:

printf("string: Hello\n");

但现在字符串可以保存在一个数组里面,用%s来打印就很有必要了:

printf("string: %s\n", str);
  • printf 会从字符数组的开头一直打印到 Null 字符

    printf会从数组str的开头一直打印到Null字符为止,Null字符本身是Non-printable字符,不打印。

    这其实是一个危险的信号:如果数组str中没有Null字符,那么printf函数就会访问数组越界,后果可能会很诡异,有时候打印出乱码,有时候看起来没错误,有时候引起程序崩溃。

8.5 多维数组

就像结构体可以嵌套一样,数组也可以嵌套,一个数组的元素可以是另外一个数组,这样就构成了多维数组(Multi-dimensional Array)。

初始化二维数组

  • 平坦初始化

    例如定义并初始化一个二维数组:

    int a[3][2] = { 1, 2, 3, 4, 5 };

    数组a有3个元素,a[0]、a[1]、a[2]。每个元素也是一个数组,例如a[0]是一个数组,它有两个元素a[0][0]、a[0][1],这两个元素的类型是int,值分别是1、2,同理,数组a[1]的两个元素是3、4,数组a[2]的两个元素是5、0。

  • Row-major and column-major

    从概念模型上看,这个二维数组是三行两列的表格,元素的两个下标分别是行号和列号。从物理模型上看,这六个元素在存储器中仍然是连续存储的,就像一维数组一样,相当于把概念模型的表格一行一行接起来拼成一串,C语言的这种存储方式称为Row-major方式,而有些编程语言(例如FORTRAN)是把概念模型的表格一列一列接起来拼成一串存储的,称为Column-major方式。

  • 嵌套初始化

    多维数组也可以像嵌套结构体一样用嵌套Initializer初始化,例如上面的二维数组也可以这样初始化:

    int a[3][2] = { { 1, 2 }, { 3, 4 }, { 5, } };
  • Memberwise initialization

    利用C99的新特性也可以做Memberwise Initialization,例如:

    int a[3][2] = { [0][1] = 9, [2][1] = 8 };
    • 结构体和数组嵌套

      结构体和数组嵌套的情况也可以做Memberwise Initialization,例如:

      struct complex_struct {
          double x, y;
      } a[4] = { [0].x = 8.0 };
      
      struct {
          double x, y;
          int count[4];
      } s = { .count[2] = 9 };
  • 嵌套使用字符串字面值初始化多维字符数组

    如果是多维字符数组,也可以嵌套使用字符串字面值做Initializer,例如:

    char days[8][10] = { "", "Monday", "Tuesday",
                         "Wednesday", "Thursday", "Friday",
                         "Saturday", "Sunday" };

数据驱动的编程

  • 简洁是因为用数据代替了代码

    这个程序和例4.1的功能其实是一样的,但是代码简洁多了。简洁的代码不仅可读性强,而且维护成本也低,像例4.1那样一堆case、printf和break,如果漏写一个break就要出Bug。这个程序之所以简洁,是因为用数据代替了代码。

  • 数据驱动的编程:选择正确的数据结构最重要

    具体来说,通过下标访问字符串组成的数组可以代替一堆case分支判断,这样就可以把每个case里重复的代码(printf调用)提取出来,从而又一次达到了“提取公因式”的效果。

    这种方法称为数据驱动的编程(Data-driven Programming),写代码最重要的是选择正确的数据结构来组织信息,设计控制流程和算法尚在其次,只要数据结构选择得正确,其他代码自然而然就变得容易理解和维护了,就像这里的printf自然而然就被提取出来了。

    参考文献[13]的第9章说:“Show me your flowcharts and conceal your tables, and I shall continue to be mystified. Show me your tables, and I won't usually need your flowcharts; they'll be obvious.”

初步接触 scanf

以往我们写的程序都只有打印输出,在这个程序中我们第一次碰到处理用户输入的情况。我们简单介绍一下scanf函数的用法,到第24.2.9节再详细解释。

scanf("%d", &man)这个调用的功能是等待用户输入一个整数并回车,这个整数会被scanf函数保存在man这个整型变量里。

  • scanf 返回值

    如果用户输入合法(输入的确实是数字而不是别的字符),则scanf函数返回1,表示成功读入一个数据。

  • scanf 也可以用转换说明

    和printf类似,scanf也可以用%c、%f、%s等转换说明。

  • 用 & 和不用 & 的情况

    如果在传给scanf的第一个参数中用%d、%f或%c表示读入一个整数、浮点数或字符,则第二个参数的形式应该是&运算符加相应类型的变量名,表示读进来的数保存到这个变量中,&运算符的作用是得到一个指针类型,到第22.1节再详细解释;

    如果在第一个参数中用%s读入一个字符串,则第二个参数应该是数组名,数组名前面不加&,因为数组类型做右值时自动转换成指针类型,在第10.2节有scanf读入字符串的例子。

    • scanf 读入字符串 10.2

      scanf("%s", input);这个调用的功能是等待用户输入一个字符串并回车,scanf把其中第一段非空白(非空格、Tab、换行)的字符串保存到input数组中,并自动在末尾添加'\0'。