谭浩强C语言第三版重点梳理:变量、函数与数组怎么学

运营资讯20小时前发布 jdkjadmin
0 00
谭浩强C语言第三版重点梳理:变量、函数与数组怎么学

谭浩强C语言第三版重点梳理:变量、函数与数组怎么学

学 C 语言,变量、函数和数组是从“能看懂程序”走向“能写出程序”的三块基础。它们看似互不相干:变量保存数据,函数组织操作,数组成批保存数据;实际编程时,三者总是一起出现。一个函数接收数组中的数据,处理后返回结果;变量的作用域和生命周期,又决定这些数据何时可用、在哪里可用。

学习早期常见的困难,不是记不住语法,而是把几个容易混淆的概念混在一起:变量名和变量值、形参和实参、数组长度和数组容量、字符串和字符数组。本文按“概念—边界—实践”的顺序梳理,并用可独立编译运行的代码说明。文中代码使用标准 C 写法,可用 GCC 或 Clang 编译,例如:gcc -std=c11 -Wall -Wextra -pedantic main.c -o main。不同教材版本、编译器和年代的环境可能存在差异;遇到旧式写法时,应优先理解其含义,再确认它是否符合当前标准和编译器支持情况。

一、变量:先弄清楚“值存在哪里”

1. 声明、定义和初始化

变量是有类型、有名称的一段可访问存储。声明告诉编译器变量的类型和名字;定义会为对象提供存储空间;初始化则是在创建对象时给它一个初始值。在初学代码中,这些操作常写在同一行:

#include <stdio.h>

int main(void)
{
    int count = 3;
    double price = 12.5;
    char grade = 'A';

    printf("count = %d\n", count);
    printf("price = %.1f\n", price);
    printf("grade = %c\n", grade);
    return 0;
}

int、double 和 char 是不同类型,适合表示不同种类的数据。类型影响对象的表示方式、可进行的运算以及与输入输出函数配合时使用的格式。不要为了“省事”把所有数都当成一种类型:整数计数通常用整数类型,带小数的测量值通常用浮点类型,单个字符用字符类型。

局部变量如果没有初始化,其值是不确定的,读取它可能导致未定义行为。下面这种写法尤其危险:

int total;
printf("%d\n", total);

应在使用前赋予确定值,或确保所有控制路径都会先写入值。文件作用域的对象和具有静态存储期的对象若未显式初始化,会被零初始化,但这不意味着可以忽略局部变量的初始化规则。

2. 作用域与生命周期不是一回事

作用域回答“在源代码的什么范围内能通过名字访问对象”;生命周期回答“对象在运行期间什么时候存在”。这两个概念要分开理解。

  • 在函数或代码块内定义的普通局部变量,通常具有块作用域,执行离开该块后,其生命周期结束。
  • 在函数外定义的对象具有文件作用域,通常可在该源文件中按名字访问;使用 static 可以限制其链接属性,使其不被其他源文件按外部名称访问。
  • 块内用 static 声明的局部对象仍只有块作用域,但生命周期贯穿程序执行期间,并保留上次调用留下的值。

不要返回普通局部变量的地址。函数返回后,该局部对象的生命周期已经结束,指向它的指针不能再用于访问原对象。对于跨函数保存的数据,通常由调用者提供存储,或使用合适的动态内存管理方式;具体选择要结合对象所有权和生命周期设计。

3. 类型转换和输入输出格式

表达式中不同算术类型相遇时,C 会按语言规则进行转换。需要留心整数除法:

#include <stdio.h>

int main(void)
{
    int a = 7;
    int b = 2;

    printf("%d\n", a / b);                 /* 输出 3 */
    printf("%.1f\n", (double)a / b);       /* 输出 3.5 */
    return 0;
}

两个整数相除,结果仍是整数,小数部分被舍去。若需要小数结果,至少应在除法前把一个操作数转换为浮点类型。只在结果处强制转换,不能恢复已经舍去的小数,例如 (double)(a / b) 仍会先做整数除法。

printf 和 scanf 的格式说明符必须与实参类型匹配。常见错误是把 double 用 %d 输出,或把 int 变量的地址传给要求 double * 的转换说明。格式不匹配可能造成未定义行为,不只是显示错一个数字。使用 scanf 读取 int 时传入变量地址,如 scanf("%d", &n);读取 double 使用 %lf。在 printf 中输出 double 则使用 %f。

二、函数:把任务拆开,并明确输入输出

1. 声明、定义、调用

函数让程序可以按职责拆分。一个函数通常有返回类型、函数名、参数列表和函数体。调用函数前,编译器需要知道它的类型信息;因此可以先写函数原型,再在后面定义函数。

#include <stdio.h>

int square(int value);

int main(void)
{
    int result = square(6);
    printf("%d\n", result);
    return 0;
}

int square(int value)
{
    return value * value;
}

int square(int value); 是函数声明,告诉编译器函数名、返回类型和参数类型;带函数体的部分是函数定义。函数调用中的 6 是实参,定义中的 value 是形参。调用时,实参的值按值传给形参,函数内对形参的赋值不会直接改变调用者的普通变量。

2. C 的参数传递是按值传递

“按值传递”是理解函数行为的关键。即使实参是指针,传入函数的也仍是指针值的副本。函数可以通过这份指针访问其所指对象,因此能够修改对象内容;但若只在函数里把形参指针改指向别处,调用者的指针本身不会因此改变。

#include <stdio.h>

void set_to_zero(int value)
{
    value = 0;
}

void set_pointed_value_to_zero(int *value)
{
    if (value != NULL) {
        *value = 0;
    }
}

int main(void)
{
    int number = 9;

    set_to_zero(number);
    printf("%d\n", number);  /* 仍为 9 */

    set_pointed_value_to_zero(&number);
    printf("%d\n", number);  /* 变为 0 */
    return 0;
}

第二个函数通过指针修改调用者对象。传指针并不会自动提供安全保障:调用者仍须传入有效地址,函数也须遵守该对象的类型、生命周期和可访问范围。若参数允许为空指针,函数应明确检查;若不允许,则应在接口说明和调用处保证非空。

3. 返回值与多个结果

函数只能直接返回一个返回值。需要传回多个结果时,可以让调用者提供多个输出参数,或返回一个结构体。输出参数必须有明确的容量和有效性约定。对于可能失败的操作,不能只返回一个看似正常的数据而丢掉错误信息;可以采用状态码加输出参数、专门的错误值,或结构体等方式表达结果。

函数的返回类型应与实际用途一致。非 void 函数应在所有正常执行路径上返回合适类型的值;void 函数用于不需要返回值的操作。把无返回值的函数声明成有返回类型,或忽略可能重要的返回结果,都会让接口含义变得不清楚。

4. 参数个数和类型的边界

普通函数调用必须按函数声明提供符合要求的实参,参数类型需要与形参兼容。C 语言不会自动知道指针所指内存有多大,也不会替函数检查数组下标。函数设计者必须决定并记录这些约束,例如“数组指针必须非空”“元素个数必须大于零”“输出缓冲区至少有指定容量”。

没有参数的函数应写成 int f(void) 这样的形式,清楚表示不接收参数。在现代 C 中,不应把旧式的空参数列表误认为严格的“无参数声明”。如果项目必须兼容非常老的编译器,应根据其标准模式核对行为,而不是混用不同年代的函数声明习惯。

三、数组:一组同类型对象,不是自动带长度的容器

1. 下标从零开始,合法范围要自己保证

数组由多个同类型元素组成,下标从 0 开始。一个含 n 个元素的数组,合法下标是 0 到 n - 1。越界访问不会由 C 语言自动转换成安全操作;程序一旦访问数组边界之外的元素,行为未定义,可能表现为结果错误、数据损坏或崩溃。

#include <stdio.h>

int main(void)
{
    int scores[5] = {82, 91, 76, 88, 95};
    int sum = 0;
    size_t count = sizeof scores / sizeof scores[0];

    for (size_t i = 0; i < count; ++i) {
        sum += scores[i];
    }

    printf("人数:%zu,平均分:%.1f\n",
           count, (double)sum / count);
    return 0;
}

这里的循环条件是 i < count,因此最后访问的下标是 count - 1。常见错误是写成 i <= count,多访问一次;或从 1 开始循环,漏掉第一个元素。长度计算表达式 sizeof scores / sizeof scores[0] 只适用于当前作用域中确实是数组对象的情况。

2. 数组作为函数参数时不会带着长度自动传入

在函数参数位置,数组形式的参数声明会调整为指针形式。因此,下面的形参 values 实际上是指向 int 的指针,并不是一个能让函数自动得知元素个数的数组。函数中对它使用 sizeof values,得到的是指针大小,不是原数组的字节数。

#include <stdio.h>
#include <stddef.h>

int sum_array(const int values[], size_t count)
{
    int sum = 0;

    if (values == NULL) {
        return 0;
    }

    for (size_t i = 0; i < count; ++i) {
        sum += values[i];
    }
    return sum;
}

int main(void)
{
    int data[] = {4, 7, 10};
    size_t count = sizeof data / sizeof data[0];

    printf("总和:%d\n", sum_array(data, count));
    return 0;
}

将数组长度作为单独参数传入,是常见且清晰的约定。const int values[] 表示函数不会通过这个参数修改元素;它并不让数组自动变成只读内存,也不能阻止别的途径修改对象。示例选择空指针时返回零,只是为了展示一种约定;对于真实接口,零可能也是有效总和,调用者就无法区分“空指针错误”和“正确结果为零”。更稳妥的设计可以返回状态码,并通过输出参数交付总和。

3. 二维数组的形状也必须说明

二维数组在内存中仍是按行连续排列的数组。将二维数组传给函数时,列数会影响编译器计算元素地址,因此函数参数必须保留列数信息,或采用适合的指针形式。不能简单写成“一个二维数组指针”而省略必要的维度。

#include <stdio.h>

enum { COLS = 3 };

int row_sum(const int table[][COLS], size_t row)
{
    int sum = 0;

    for (size_t col = 0; col < COLS; ++col) {
        sum += table[row][col];
    }
    return sum;
}

int main(void)
{
    int table[2][COLS] = {
        {1, 2, 3},
        {4, 5, 6}
    };

    printf("第二行总和:%d\n", row_sum(table, 1));
    return 0;
}

函数仍需保证传入的 row 小于实际行数。这个接口把列数固定为三,但没有接收总行数,因此调用者必须保证行下标合法。若行数也可变,应将它作为参数传入,并设计清晰的维度约定;不能只依赖指针本身推断实际大小。

四、字符数组与字符串:结尾标记不可少

C 风格字符串是以空字符 '\0' 结束的字符序列。它与“字符数组”不是同义词:字符数组可能装普通字符,也可能装字符串;只有存在正确的终止空字符并满足容量要求,才能作为字符串交给 printf 的 %s 等接口处理。

#include <stdio.h>
#include <string.h>

int main(void)
{
    char word[6] = "hello";

    printf("%s,长度为 %zu\n", word, strlen(word));
    return 0;
}

word 有 6 个元素:五个可见字符加一个终止空字符。若数组只有 5 个元素,再把六个字符写进去,就会越界。strlen 计算的是终止空字符之前的字符数,不包含终止符;它要求参数指向一个有效的、以空字符结尾的字符串。若终止符不存在,strlen 可能继续读出对象边界,导致未定义行为。

字符串输入尤其要注意容量。直接使用无宽度限制的 scanf("%s", buffer),输入过长时可能写出数组边界。读取一整行时,可以优先使用 fgets,并检查返回值:

#include <stdio.h>
#include <string.h>

int main(void)
{
    char line[64];

    printf("请输入一行文字:");
    if (fgets(line, sizeof line, stdin) == NULL) {
        fprintf(stderr, "读取失败或没有输入\n");
        return 1;
    }

    size_t length = strlen(line);
    if (length > 0 && line[length - 1] == '\n') {
        line[length - 1] = '\0';
    }

    printf("你输入的是:%s\n", line);
    return 0;
}

fgets 最多读取数组容量减一的字符,并留位置写入终止空字符;如果一行长于缓冲区,换行符可能尚未读到,剩余内容会留在输入流中。程序若要继续读取后续输入,应处理这类情况,而不是假设一行一定完整装入。安全使用字符串函数的核心不是背一个“安全函数”名单,而是始终确认目标容量、源数据长度、终止符和返回值约定。

五、三个主题合起来:写一个有边界检查的统计函数

下面的程序读取一组已知长度的整数,对数组求和并计算平均值。它展示了变量初始化、函数拆分、数组长度传递和错误状态处理。为便于集中说明边界,数据直接写在数组中;把数据改为外部输入时,还要额外检查输入函数是否成功。

#include <stdio.h>
#include <stddef.h>

int average(const int values[], size_t count, double *result)
{
    if (values == NULL || result == NULL || count == 0) {
        return 0;
    }

    long long sum = 0;
    for (size_t i = 0; i < count; ++i) {
        sum += values[i];
    }

    *result = (double)sum / (double)count;
    return 1;
}

int main(void)
{
    int values[] = {72, 85, 91, 68};
    size_t count = sizeof values / sizeof values[0];
    double result = 0.0;

    if (!average(values, count, &result)) {
        fprintf(stderr, "参数无效,无法计算平均值\n");
        return 1;
    }

    printf("平均值:%.2f\n", result);
    return 0;
}

返回值用于报告函数是否成功,结果通过 result 输出。调用者必须传入有效的结果地址,函数通过判空避免直接解引用空指针;但判空不能验证一个非空地址是否有效,也不能证明它指向足够大的对象。函数的契约仍要求调用者传入可写的 double 对象地址。

此处使用 long long 累加,降低普通整数求和时溢出的风险,但并不能对任意数量、任意大小的输入保证绝不溢出。若输入范围可能很大,应依据元素范围和数量设计上界检查或采用适当的数据表示。整数溢出不是可以忽略的“自然回绕”,有符号整数溢出在 C 中属于未定义行为。

六、常见错误速查

  • 读取未初始化的局部变量:先赋值,或确保每条执行路径都完成初始化。
  • 把整数除法当成浮点除法:在除法前转换操作数,而不是只转换结果。
  • 混淆形参和实参:形参是函数定义中的局部参数对象,普通实参按值传入;想修改调用者对象时,需要传入合适的指针。
  • 以为数组形参保留长度:函数接收数组参数时不会自动携带实际元素个数,通常要同时传入长度。
  • 用函数内的 sizeof 推断传入数组长度:数组形参已调整为指针,sizeof 得到的是指针大小。
  • 循环条件多一次:有 n 个元素时,常用 i < n,下标范围为 0 到 n - 1。
  • 把字符数组直接当字符串:交给字符串函数前,确认缓冲区容量足够且有终止空字符。
  • 忽视函数返回值:输入失败、文件操作失败或自定义函数返回错误时,应按接口约定处理,不能无条件使用结果。
  • 返回局部变量地址:函数结束后局部对象生命周期结束,不能把其地址当成有效结果返回。

七、怎么学得更扎实

第一步,练习读声明和写小程序。每次定义变量,都能说清它的类型、作用域和初始值;每次访问数组,都能指出下标范围;每次调用函数,都能区分参数类型、实际传入值和返回值。

第二步,把数组练习改写成函数。先写一个求和函数,再增加长度参数;再尝试让函数不修改输入数组,并用 const 表达这一点。接着设计一个能报告失败的接口,不要把错误和正常结果混为一谈。

第三步,主动测试边界:长度为零、最小容量、最大容量、空指针、输入为空、输入超长,以及循环正好走到最后一个元素的情况。边界测试不是为了证明程序绝对正确,而是帮助暴露对接口约定和下标范围的误解。

最后,养成编译器警告和运行时检查的习惯。使用较新的 C 编译器时,可打开常见警告;调试阶段还可以使用 AddressSanitizer 等工具检查部分内存越界和生命周期错误,例如在支持的平台上用 gcc -std=c11 -Wall -Wextra -g -fsanitize=address,undefined main.c -o main 编译。此类工具能发现部分问题,不能替代正确的边界设计,也不能证明程序没有漏洞。

变量让数据有明确类型和生命周期,函数让操作有清楚的输入输出,数组让同类数据按顺序组织起来。真正掌握这三者,不是背下几个语法形式,而是始终追问:对象在哪里、有效多久、函数能访问什么、数组有多大、调用者和被调用者分别负责什么。能把这些问题说清楚,后续学习指针、结构体、文件和动态内存时,才有可靠的基础。

© 版权声明

相关文章