C语言程序设计谭浩强版:从基础语法到函数的学习路线

技术教程18小时前发布 jdkjadmin
0 00
C语言程序设计谭浩强版:从基础语法到函数的学习路线

C语言程序设计谭浩强版:从基础语法到函数的学习路线

谭浩强编著的《C程序设计》长期被用作 C 语言入门教材。不同版次在章节安排、编译环境和示例细节上可能有所不同,但从基础语法走到函数,核心学习路径大致一致:先弄清程序如何编译运行,再掌握表达式、分支和循环,随后学习数组与字符串,最后用函数拆分程序,并理解参数和返回值的边界。

这条路线适合第一次学习 C 的读者,也适合学过一些语法、但写程序时容易混淆类型、数组边界或函数参数的人。学习时不必把教材代码原样背下来。更有效的做法是:每学一个概念,就写一个短程序,主动修改输入和边界条件,再编译运行验证。

一、先确定学习环境和目标

C 是需要编译的语言。源代码通常先由编译器翻译成目标文件或可执行文件,再由操作系统运行。常见的入门工具有 GCC、Clang 和集成开发环境。使用图形界面可以减少初期配置工作,但建议尽早熟悉命令行编译,这样更容易分清代码错误、编译错误和运行结果。

下面是一个最小的完整程序:

#include <stdio.h>

int main(void)
{
    printf("Hello, C!\n");
    return 0;
}

将代码保存为 hello.c。安装了 GCC 的环境中,可以运行:

gcc -std=c11 -Wall -Wextra -Wpedantic hello.c -o hello
./hello

在 Windows 命令提示符中,生成程序后通常运行 hello.exe;具体命令取决于使用的工具链。-std=c11 指定语言标准,-Wall、-Wextra 等选项用于启用一组常见警告。警告不是错误,但初学阶段应认真查看,不要为了让输出变干净而随意关闭警告。

学习目标先设得具体一些:能够读懂基础语句,能够编译并定位常见错误,能够把一个小问题分解成输入、处理和输出,并用函数组织代码。数据结构、文件、指针深入用法和动态内存都可以在掌握基础后继续学习,不必一开始就全部铺开。

二、从程序结构、变量和类型开始

一个 C 程序由声明和语句等部分组成。执行通常从 main 函数开始。#include <stdio.h> 是预处理指令,用于引入标准输入输出函数的声明;printf 则是标准库函数。花括号表示代码块,分号通常用于结束声明或表达式语句。

变量用于保存数据。声明时要指定类型,例如 int 常用于整数,double 常用于浮点数,char 用于字符。类型不仅影响能存储什么,也会影响运算规则和格式化输入输出方式。

#include <stdio.h>

int main(void)
{
    int count = 12;
    double price = 3.5;
    char grade = 'A';

    printf("数量:%d\n", count);
    printf("单价:%.2f\n", price);
    printf("等级:%c\n", grade);

    return 0;
}

printf 的格式说明符必须与传入参数的类型相匹配。常见对应关系包括:%d 用于 int,%f 用于输出浮点数,%c 用于字符,%s 用于以空字符结尾的字符串。浮点数传给可变参数函数时会进行默认参数提升,因此 printf 用 %f 输出 double 是常见写法。格式符写错可能导致未定义行为,不能指望程序总能给出“看起来合理”的结果。

整数运算也有边界。整数除法会舍弃小数部分,例如 7 / 2 的结果是 3。如果需要小数结果,应让参与运算的值至少有一个是浮点数,例如 7.0 / 2。有符号整数溢出在 C 中属于未定义行为;无符号整数超出范围则按模数回绕,但这并不意味着适合忽略边界。对可能很大的计数和乘法,应先判断范围,必要时选用合适类型。

const 可以表示程序不应通过某个名字修改对象,例如 const double rate = 0.05;。它有助于表达意图,但不是加密或防篡改机制。变量名也应说明用途,避免只使用 a、b、x1 等难以维护的名称。

三、表达式与输入输出:先学会检查返回值

表达式由变量、常量和运算符组成。除法、取余和比较运算尤其值得练习。取余运算符 % 用于整数运算,常见用途是判断奇偶、循环下标或时间单位换算。运算符优先级容易造成误读,复杂表达式可以通过括号明确意图。

初学时常用 scanf 读取输入,但不能假定输入一定合法。scanf 会返回成功赋值的项目数;如果返回值与预期不符,变量可能没有得到新的有效值。下面的程序读取一个整数,并检查输入是否成功:

#include <stdio.h>

int main(void)
{
    int number;

    printf("请输入一个整数:");
    if (scanf("%d", &number) != 1) {
        fprintf(stderr, "输入不是有效整数。\n");
        return 1;
    }

    printf("你输入了:%d\n", number);
    return 0;
}

&number 表示取得变量的地址。scanf 需要地址,才能把读取的结果写入变量。对于 int 应使用 %d,对于 double 应使用 %lf;而 printf 输出 double 时使用 %f。混淆两者是入门阶段常见错误。

scanf("%s", buffer) 读取字符串时,遇到空白字符便会停止;若不指定宽度,输入过长还可能越过数组边界。即使指定宽度,也要给字符串末尾的空字符留出一个位置。例如,大小为 32 的字符数组最多容纳 31 个普通输入字符以及结尾的 '\0'。处理整行文本时,使用 fgets 通常更容易明确缓冲区容量:

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

int main(void)
{
    char name[32];

    printf("请输入名字:");
    if (fgets(name, sizeof name, stdin) == NULL) {
        fprintf(stderr, "读取失败。\n");
        return 1;
    }

    name[strcspn(name, "\n")] = '\0';
    printf("你好,%s!\n", name);
    return 0;
}

fgets 最多读取数组容量减一的字符,并尝试写入结尾空字符;若输入行过长,剩余部分仍可能留在输入流中。上例删除了读入的换行符,适用于这一简单交互场景。真实程序需要根据输入格式决定如何处理超长行和残留输入,不能把截断后的内容当作完整数据。

四、用分支表达条件,用循环控制重复

if 适合根据条件选择执行路径。条件表达式的结果为真或假;在 C 中,零表示假,非零表示真。判断相等要用 ==,赋值使用 =。把二者写混,可能造成逻辑错误,也可能在某些情况下编译通过,因此要特别留意。

#include <stdio.h>

int main(void)
{
    int score;

    printf("请输入 0 到 100 的分数:");
    if (scanf("%d", &score) != 1) {
        fprintf(stderr, "输入无效。\n");
        return 1;
    }

    if (score < 0 || score > 100) {
        printf("分数超出范围。\n");
    } else if (score >= 60) {
        printf("及格。\n");
    } else {
        printf("未及格。\n");
    }

    return 0;
}

这个例子先检查范围,再进行分类。先处理非法值可以减少后续分支中的重复判断。条件 score < 0 || score > 100 表示两个条件只要有一个成立就为真;&& 则要求两边都为真。

switch 适用于根据一个整数类型或枚举值选择多个固定分支的场景。需要注意 break:缺少它时,执行可能继续进入后续分支,这种贯穿有时是刻意设计,但初学时常是疏漏。条件范围判断通常仍用 if 更直接。

while、do...while 和 for 都能实现循环。循环的核心是初始化、继续条件和更新操作。若更新遗漏,或条件永远成立,程序可能陷入无限循环。

#include <stdio.h>

int main(void)
{
    int n;
    int i;
    long long sum = 0;

    printf("请输入 1 到 1000 的整数:");
    if (scanf("%d", &n) != 1 || n < 1 || n > 1000) {
        fprintf(stderr, "输入无效或超出范围。\n");
        return 1;
    }

    for (i = 1; i <= n; ++i) {
        sum += i;
    }

    printf("1 到 %d 的和是 %lld\n", n, sum);
    return 0;
}

这里使用 long long 保存累加结果,并限制 n 的范围。范围限制不是所有程序都必须设为 1000,而是要根据需求和所选类型计算最大可能结果。若循环计数器和累加器可能达到类型上限,不能依靠溢出后的结果继续计算。

常见循环错误还包括边界多一或少一:数组合法下标通常从 0 开始,长度为 n 的数组的有效下标是 0 到 n - 1。因此遍历数组时常写作 i < n,而不是 i <= n。

五、数组:一组同类型数据,不是自动检查边界的容器

数组用于保存一组相同类型的元素。定义数组时,其长度必须是正值;在常见的初学程序里,数组通常使用编译期确定的长度。访问数组时,C 不会自动检查下标是否越界。越界读写可能破坏其他数据,造成错误结果或安全问题,不能把“运行时没崩溃”当成合法。

下面的程序读取 5 个整数,计算最大值和平均值:

#include <stdio.h>

int main(void)
{
    int values[5];
    int i;
    int max;
    long long sum = 0;

    for (i = 0; i < 5; ++i) {
        printf("请输入第 %d 个整数:", i + 1);
        if (scanf("%d", &values[i]) != 1) {
            fprintf(stderr, "输入无效。\n");
            return 1;
        }
        sum += values[i];
    }

    max = values[0];
    for (i = 1; i < 5; ++i) {
        if (values[i] > max) {
            max = values[i];
        }
    }

    printf("最大值:%d\n", max);
    printf("平均值:%.2f\n", (double)sum / 5);
    return 0;
}

这里先读取并累加,再从第二个元素开始寻找最大值,因为第一个元素已经作为初始最大值。平均值计算时将 sum 转为 double,否则整数除法会舍弃小数部分。long long 能扩大累加范围,但仍有上限;如果输入范围或元素数量更大,还需要进行更严格的溢出分析。

在把数组交给函数时,它不会自动携带长度信息。函数需要通过额外参数接收元素个数,调用者也必须保证传入的长度与实际数组相符。把数组长度写在函数接口中,是避免越界的重要习惯,但并不等于编译器会替你验证实际大小。

六、字符数组与字符串:空字符也是数组内容

C 语言没有内置的独立字符串类型。常见字符串是字符数组中以空字符 '\0' 结尾的一段内容。字符串的可见字符数不等于所需数组大小:存放长度为 5 的字符串 "hello",至少需要 6 个字符位置,以容纳结尾的空字符。

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

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

    printf("内容:%s\n", word);
    printf("长度:%zu\n", strlen(word));
    printf("数组容量:%zu\n", sizeof word);
    return 0;
}

strlen 返回空字符之前的字符数,不包含空字符;sizeof word 在这里返回整个数组占用的字节数。二者不是同一概念。数组一旦作为函数参数传递,通常会转换为指向首元素的指针,在函数内部对参数使用 sizeof 得到的不是原数组容量。因此,长度应作为单独参数传递,或在调用方先取得。

字符串复制和拼接也要考虑目标数组容量。诸如 strcpy、strcat 的函数本身不会知道目标缓冲区实际有多大,调用者必须保证空间足够。使用长度受限的函数也不能免除审查:需要确认参数的含义、是否写入结尾空字符,以及截断时的处理方式。处理不可信输入时,应在复制前检查长度,并确保所有路径都不会越过缓冲区。

七、函数:把程序拆成可理解、可复用的部分

函数把一段操作封装成有名字的单元。函数通常有返回类型、函数名、参数列表和函数体。将问题拆成小函数,能减少重复代码,也更方便分别验证。不是每段代码都值得拆分;拆分后的函数应承担清晰职责,名称能说明它做什么。

#include <stdio.h>

int max_of_two(int a, int b);

int main(void)
{
    int x;
    int y;

    printf("请输入两个整数:");
    if (scanf("%d %d", &x, &y) != 2) {
        fprintf(stderr, "输入无效。\n");
        return 1;
    }

    printf("较大值是:%d\n", max_of_two(x, y));
    return 0;
}

int max_of_two(int a, int b)
{
    if (a > b) {
        return a;
    }
    return b;
}

函数声明,也叫函数原型,告诉编译器函数的名字、返回类型和参数类型。上例中,原型放在 main 之前,定义放在后面;也可以把定义写在调用它的代码之前。声明和定义应保持一致。把函数写成没有参数却省略参数列表,在不同 C 语言标准和编译选项下可能产生不同含义或诊断;表示确实没有参数时,应写 void,例如 int main(void)。

C 语言的普通参数传递采用值传递:调用函数时,形参得到实参值的副本。修改形参本身不会改变调用者的普通变量。若要让函数修改调用者对象,通常传递对象的地址,也就是使用指针参数。

#include <stdio.h>

void add_one(int *value);

int main(void)
{
    int number = 10;

    add_one(&number);
    printf("%d\n", number);
    return 0;
}

void add_one(int *value)
{
    if (value != NULL) {
        ++*value;
    }
}

调用时的 &number 取得变量地址,函数中的 *value 访问该地址所指向的整数。指针参数需要明确约定是否允许为空;上例通过检查 NULL 避免解引用空指针。这个检查只能处理空指针,不能证明非空指针一定有效:传入悬空指针、越界地址或已释放对象的地址仍然会造成未定义行为。

函数可以用返回值报告结果。返回值不够表达多项结果时,可以使用指针参数输出额外结果,但接口应清楚说明哪些参数必须有效、长度如何计算、失败时输出参数是否被修改。不要从函数返回局部自动变量的地址:函数结束后,该局部对象的生命周期已经结束,返回的指针不能再用于访问它。

例如,求数组元素之和的函数可以把数组和长度分别作为参数:

#include <stdio.h>

long long sum_array(const int values[], size_t length);

int main(void)
{
    int data[] = {3, 5, 8, 2};
    size_t length = sizeof data / sizeof data[0];

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

long long sum_array(const int values[], size_t length)
{
    size_t i;
    long long sum = 0;

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

这段程序可独立编译运行。sizeof data / sizeof data[0] 在 data 仍是数组的作用域内,计算出元素个数;size_t 是标准库中用于表示对象大小的无符号整数类型,需包含相应标准头文件。这里通过 const 表示函数不会通过该参数修改数组元素,但这不代表数组在其他位置不可修改。

函数参数声明中的 const int values[] 在函数形参语境中会调整为指针形式。函数并不知道数组实际有多长,所以调用者必须保证 length 不大于可访问元素数。如果长度来源于外部输入,还应先验证它的上限,再进行下标访问或内存分配。

八、常见错误与安全边界

  • 把警告当作无关信息。编译器警告可能指出类型不匹配、隐式转换或可疑逻辑。先理解原因,再决定是否需要调整代码。
  • 混用赋值和比较。= 是赋值,== 是比较。条件表达式要仔细检查,必要时拆成更易读的语句。
  • 忽略输入函数的返回值。输入失败时,变量可能未被赋予有效输入值。检查成功读取的项目数,并决定如何报告或恢复。
  • 越界访问数组或字符串。C 通常不会替程序自动检查下标。明确数组容量、元素数量和字符串结尾空字符的位置。
  • 格式说明符不匹配。printf 和 scanf 的类型要求不同;变参格式不匹配可能导致未定义行为。仔细核对格式串和实参。
  • 把浮点数当作精确十进制数。许多小数无法用二进制浮点精确表示。比较结果时应结合问题需求设计容差,而不是随意断言两个浮点值必然相等。
  • 漏写返回值或错误路径。非 void 函数应按接口约定返回结果。对可能失败的操作,要设计清楚失败信号并由调用方检查。
  • 让函数依赖隐含状态。大量使用全局变量会使调用关系难以追踪。初学时优先通过参数传入依赖,通过返回值或明确的输出参数传回结果。

输入检查和边界检查有助于降低程序出错与被恶意输入利用的风险,但一段演示代码不等于经过安全审计的程序。涉及网络服务、文件解析、用户数据或资金计算时,还需要考虑资源限制、整数溢出、编码与格式验证、错误处理、权限边界、测试覆盖和工具链配置。不要把某个函数或编译选项宣传成绝对安全的保证。

九、按阶段练习,不要只看代码

  1. 程序与类型:练习输出固定文本、读入两个数、计算面积和单位换算。每次都检查输入是否成功,并观察整数与浮点运算的差异。
  2. 分支与循环:写奇偶判断、分数分级、累加求和、猜数或菜单程序。手动测试最小值、最大值、零和非法输入。
  3. 数组与字符串:练习求最大值、平均值、统计字符数量和处理一行输入。记录每个数组的容量、有效下标范围与字符串终止位置。
  4. 函数:把前面的程序改写成多个函数,例如输入检查、计算和输出分别处理。让每个函数有明确参数和返回值,并通过不同输入测试它。
  5. 复盘错误:保留编译器警告和失败用例,写明错误原因及修复方式。能解释错误,比只记住一段正确代码更有价值。

遇到问题时可以按顺序排查:先看编译器指出的文件和行号,再确认变量类型、格式说明符、数组边界和函数声明;程序能运行但结果不对时,使用小输入逐步打印中间值;问题与边界有关时,测试空输入、零、最大允许值、超范围值和重复数据。调试不是额外负担,而是学习程序执行过程的一部分。

结语

从基础语法学到函数,关键不在于尽快记完所有语法,而在于建立一套可靠的写程序方法:明确数据类型和范围,检查输入与返回值,控制数组边界,用函数表达清晰职责,并通过编译器警告和边界测试验证假设。按这条路线阅读谭浩强版教材,再用短程序复现和改写例题,既能打好入门基础,也能为后续学习指针、结构体、文件和更大型的 C 程序做好准备。

© 版权声明

相关文章