1 C 语言常量基础

1.1 常量的概念与意义

        在 C 语言中,常量(Constant)是指在程序运行期间,其值固定不变、不可修改的数据项。

        这与变量形成了鲜明对比:变量的值允许在程序运行时被改变;而常量的值一旦被定义,在整个程序运行过程中都不能被再次修改。

        这个 “固定不变” 的特性在编程和现实逻辑中非常常见。 为了直观理解,我们可以看几个例子:

  1. 固定定义:例如一周固定有 7 天,这个数值在任何逻辑中都不应被改变。
  2. 协议标准:例如 HTTP 状态码中,200 固定表示 “请求成功”,404 固定表示 “未找到资源”,这些代码具有标准含义,必须保持一致。
  3. 数学常数:例如圆周率 π(约 3.14159),它在数学计算中是一个公认的固定值。
  4. 配置限制:例如网络协议中规定数据包最大长度(MTU)为 1500 字节,在处理数据包时,这个 1500 就是一个不应被随意修改的限制值。

        可以看到,这些值在逻辑上都具有 “恒定不变” 的特性。 在编程中,如果一个数值也具有这种特性(即不应该或不允许在程序运行时被修改),就强烈建议将其定义为常量。

        这样做的好处非常明确:

  1. 提升可读性与可维护性:使用有意义的常量名(如 MAX_MTU)代替无意义的 “魔法数字”(如 1500),使代码更易理解。当需要修改该值时(例如协议升级,MTU 变为 9000),只需修改常量的定义处即可。
  2. 保证数据稳定性:常量的值在程序运行时将受到保护,防止在代码的其他地方被意外篡改,确保了数据的稳定性。
  3. 提升代码可靠性:防止因本应不变的数据被错误修改而导致的程序 Bug,从根本上提升了软件的整体可靠性。

📚 扩展:什么是 “魔法数字”(Magic Number)?

        “魔法数字” 是一个常见的编程术语,指的是在代码中直接使用的、但含义不明确的数值(即 “硬编码” 的数字)。

        例如,在计算价格时写:total_price = item_price * 1.08;

        在这个例子中,1.08 就是一个典型的 “魔法数字”。 这种写法会带来显而易见的坏处:

  1. 可读性差:其他人(或未来的你)很难理解 1.08 到底代表什么(比如:是 8% 的税率?还是 8% 的服务费?)。
  2. 可维护性差:如果这个 1.08(假设是税率)在 10 个不同的地方都用了,当税率调整时,你需要手动修改 10 次,非常麻烦且容易出错。

        为了解决这些问题, 我们就应该给这个数字起一个有意义的名字(例如 TAX_RATE),并用这个名字来代替它。在 C 语言中,这种有名字且不变的值,就是我们所说的常量。

1.2 常量的分类

        在 C 语言中,常量根据其表现形式可以分为两类:字面量常量(Literal Constant)和符号常量(Symbolic Constant)。

字面量常量

  • 定义:字面量常量(或称 “字面值”)是指直接写在代码中的固定值,它们本身就代表了其自身的数值,无需额外定义。
  • 特点:值固定、不可修改,在编译时被编译器直接处理。
  • 常见类型:
    • 整型常量:如 100、-5,默认类型为 int。
    • 浮点型常量:如 3.14、2.0e5,默认类型为 double。
    • 字符常量:如 'A'、'\n'。在 C 语言中其类型为 int(表示该字符的 ASCII 值)。
    • 字符串常量:如 "Hello"。其类型为 “char 类型的数组”。
  • 示例:
// 1. 整型字面量常量
int http_status = 200; // 200 (HTTP 成功状态) 是整型常量
int max_mtu = 1500;    // 1500 (MTU 限制) 也是整型常量

// 2. 浮点型字面量常量
double pi = 3.14159; // 3.14159 (圆周率) 是浮点型常量

// 3. 字符字面量常量
char grade = 'A'; // 'A' 是字符常量

// 4. 字符串字面量常量
printf("Hello, World!"); // "Hello, World!" 是字符串字面量常量

        字面量常量用起来很方便,但它有一个明显的缺点:它本身就是 “魔法数字”。

        正如我们在 1.1 节中讨论的,如果 1500 这个值在程序中多处使用(例如代表 “最大 MTU 长度”),当需要将其修改为 9000 时,维护起来将非常困难。

        为了解决字面量常量(魔法数字)带来的可读性和可维护性问题,我们就需要用到第二类常量:符号常量。

符号常量

  • 定义:符号常量是指使用一个有意义的标识符(名称)来代表的常量。它必须在使用前进行定义。
  • 常见定义形式:
    • 使用 #define 预处理指令定义的宏常量。
    • 使用 const 关键字修饰的 “只读变量”(常变量)。
    • 使用 enum 关键字定义的枚举常量。
    • 在本篇教程中,我们将重点讲解前两种方式(#define 和 const),枚举类型(enum)将在后续的章节中详细介绍。

2 常量的定义方式

2.1 使用 #define 定义符号常量

基本概念与语法格式

        在 C 语言中,#define 是一种预处理指令。它允许我们定义一个 “符号常量”,这本质上是一个名称与固定值之间的映射关系。

        它的基本语法格式如下:

#define 宏名 替换文本
  • 宏名:即符号常量的名称。按照编程惯例,宏名通常使用全大写字母并用下划线分隔单词(如 PI、MAX_MTU),以便与普通变量名(如 myVariable)清晰地区分,提升代码可读性。
  • 替换文本:指宏名将被替换成的具体内容,可以是数值、表达式、字符串等。
  • 示例:
// 宏名通常使用全大写字母并用下划线分隔单词
#define PI 3.14159
#define MAX_MTU 1500
#define VERSION "1.0.0"

        使用这种方式,可以将 “魔法数字”(如 1500)替换为有意义的名称(如 MAX_MTU),从而显著提升代码的可读性与可维护性。

使用规范

        在使用 #define 时,需要注意以下几点:

  1. #define 语句不以分号结尾。预处理指令不是 C 语句。如果误加了分号(如 #define PI 3.14159;),那么这个分号(;)也会被当作 “替换文本” 的一部分被替换到代码中,常常导致编译错误。
  2. 建议将 #define 定义写在函数外部(通常在文件的开头或头文件中)。这能确保其作用域覆盖整个文件(或所有包含该头文件的文件),符合 “全局常量” 的定位。
  3. #define 定义的符号常量不占用存储空间(即不占内存)。它在预处理阶段完成替换,这一点与后文将要介绍的 const 关键字不同,const 定义的常量(常变量)是会占用内存空间的。

案例演示

        下面我们通过一个完整的案例来演示 #define 的用法。

        首先,请在本地计算机上创建一个名为 Chapter3_constant 的文件夹,并使用 VS Code 打开该文件夹。然后,在此文件夹中新建一个名为 01define.c 的 C 源文件,作为本次案例演示的代码文件,如下所示:

        接着,在 01define.c 文件中输入以下示例代码:

#include <stdio.h>

// 使用 #define 预处理指令定义符号常量 PI
// 在预编译阶段,程序中所有出现的 PI 都会被替换为 3.14
// 注意:宏定义没有类型检查,也不占用内存,仅做文本替换
#define PI 3.14 // 注意:宏定义后面不需要加分号

int main(void)
{
    double area;    // 用于存储圆的面积,double 是浮点数类型
    double r = 1.2; // 初始半径设为 1.2

    // 计算面积:面积 = π × r²
    area = PI * r * r;
    // 输出结果:printf 默认输出小数点后 6 位
    // 如果需要保留到小数点后两位,则需要使用 %.2f 格式化输出
    printf("圆的面积是(保留到小数点后两位): %.2f\n", area);

    // 将半径扩大为原来的 2 倍(1.2 → 2.4)
    r = 2.4;
    // 重新计算面积(此时面积应为原来的 4 倍)
    area = PI * r * r;
    printf("圆的面积是(保留到小数点后两位): %.2f\n", area);

    return 0;
}

        程序运行结果如下(环境:Win64 + VS Code + MinGW-w64 UCRT):

处理时机:预处理阶段

        #define 的处理时机发生在 C 语言编译的第一个阶段 —— 预处理阶段。

        这一步操作称为宏替换(macro substitution),其本质是纯粹的文本替换。

        预处理器会逐行扫描源文件,查找所有已定义的 “宏名”(如 PI),并将其原封不动地替换为对应的 “替换文本”(如 3.14)。预处理器本身不涉及任何 C 语言的语法分析或类型检查(因为这是后续编译器的工作)。

        我们可以通过 gcc 命令来亲眼验证这一点:

gcc -E .\01define.c -o .\01define.i

        这条命令告诉 gcc 编译器在预处理(-E)阶段结束后就停止,并将结果输出(-o)到 01define.i 文件中。

        打开这个新生成的 01define.i 文件,你会看到代码中所有的 PI 确实都已经被替换成了 3.14,如下所示:

注意事项:纯文本替换陷阱

        理解了 #define 只是 “纯粹的文本替换” 至关重要,因为这个机制如果使用不当,会因运算符优先级导致严重的逻辑错误。

        例如,看下面这个例子:

#include <stdio.h>

#define PI 3 + 2

int main(void)
{
    // 预处理后,本行代码会变为: int i = 3 + 2 * 2;
    int i = PI * 2;

    printf("i = %d\n", i); // 输出:i = 7 而不是 i = 10

    return 0;
}

        程序运行结果如下(环境:Win64 + VS Code + MinGW-w64 UCRT):

        上面这个程序的最终输出结果是 i = 7 而不是 i = 10,原因就在于 #define 是纯文本替换:

  1. 在预处理阶段,PI 被原封不动地替换为 3 + 2。
  2. 因此,表达式 PI * 2 实际上变为了 3 + 2 * 2。
  3. 在编译阶段,由于乘法运算符(*)的优先级高于加法运算符(+),表达式将按 3 + (2 * 2) 的顺序进行计算,最终结果为 7。

        为避免此类问题,应在宏定义中使用括号来确保表达式的整体性:#define PI (3 + 2)。这样,PI * 2 就会被正确展开为 (3 + 2) * 2,计算结果为 10,符合预期。

2.2 使用 const 定义符号常量

基本概念与语法格式

        在 2.1 节中,我们学习了 #define。#define 作为一种纯文本替换,它虽然简单,但也带来了缺乏类型检查和易引发优先级错误(如 3 + 2 陷阱)的缺点。

        为了解决这些问题,C89(ANSI C)标准引入了一个新的关键字:const。

        const(constant 的缩写)用于声明一个 “只读变量”(或称 “常变量”)。它具有以下关键特性:

  1. 具有明确类型:const 定义的常量是有数据类型的(如 const int),编译器会在编译阶段对其进行类型检查,这大大提升了代码的安全性。
  2. 必须初始化:const 变量在声明时必须被初始化。
  3. 值不可更改:初始化之后,其值在程序运行期间不可被修改。

        因此,在现代 C 语言编程中,推荐优先使用 const 来定义常量,而不是 #define。

        使用 const 定义常量的基本格式如下,请注意它是一条 C 语句,末尾需要分号:

const 数据类型 常量名 = 常量值;  // 末尾需要分号

        示例:

const double PI = 3.14159;
const int MAX_MTU = 1500;
// 在程序中,PI 和 MAX_MTU 都是只读变量,其值在初始化后不可修改

 使用规范:不可变性

        const 的核心规则是 “不可变”,这体现在两个方面(这两条都是 C 语言的强制语法规定):

        1. 必须在声明时初始化:const 变量的值是 “只读” 的,你只有一次(即声明时)给它赋值的机会。如果声明时没有初始化,它后续将永远无法被赋值。如下所示:

        2. 初始化后不能被修改:任何在初始化之后试图修改 const 变量值的行为,都会被编译器阻止。如下所示:

案例演示

        接下来,我们在 Chapter3_constant 文件夹中创建一个名为 04const.c 的源文件,并编写如下代码:

#include <stdio.h>

// 使用 const 关键字定义常量 PI
// 1. 它有类型 (double)
// 2. 它是一条语句,需要分号
const double PI = 3.14;

int main(void)
{
    double area;    // 声明变量 area 用于存储圆的面积
    double r = 1.2; // 声明并初始化变量 r 为圆的半径

    // 编译器在编译时,知道 PI 是一个 double 类型的常量
    area = PI * r * r;

    // 使用 printf 函数输出圆的面积,保留两位小数
    printf("圆的面积是(保留到小数点后两位) : %.2f\n", area);

    // (下一节我们会看到,如果尝试修改 PI,编译器会报错)

    return 0;
}

        程序运行结果如下(环境:Win64 + VS Code + MinGW-w64 UCRT):

处理时机:编译阶段

        const 常量是在编译阶段进行处理的,而 #define 是在更早的预处理阶段。

        我们可以用 2.1 节中相同的 gcc -E 命令来验证这一点:

gcc -E .\04const.c -o .\04const.i

        执行该指令后,打开生成的 04const.i 文件。你会发现,const double PI = 3.14; 这行代码原封不动地保留在文件中,代码中的 PI 也没有被替换,如下所示:

        这有力地证明了:预处理器根本不关心 const,const 是交由后续的编译器来处理的 C 语言语法。

对比优势:表达式求值

        还记得 2.1 节中 #define PI 3 + 2 导致的陷阱吗?我们来看看 const 如何完美解决这个问题。

#include <stdio.h>

#define DEFINE_VAL 3 + 2     // 预处理器:纯文本替换
const int CONST_VAL = 3 + 2; // 编译器:先求值,再赋值

int main(void)
{
    // 预处理后:int i = 3 + 2 * 2;  (结果为 7)
    int i = DEFINE_VAL * 2;

    // 编译时:CONST_VAL 已经是 5
    // 相当于:  int j = 5 * 2;      (结果为 10)
    int j = CONST_VAL * 2;

    printf("define value: %d\n", i); // 输出: define value: 7
    printf("const value: %d\n", j);  // 输出: const value: 10

    return 0;
}

        程序运行结果如下(环境:Win64 + VS Code + MinGW-w64 UCRT):

原因分析:

  1. #define:预处理器把 DEFINE_VAL * 2 替换为 3 + 2 * 2,编译器根据优先级计算得到 7。
  2. const:编译器在处理 const int CONST_VAL = 3 + 2; 这条语句时,会先计算表达式 3 + 2,得到结果 5,然后将 5 作为 CONST_VAL 的值。后续代码 CONST_VAL * 2 自然就是 5 * 2,得到 10。

        结论:const 具有类型安全、在编译期处理、能正确处理表达式,是比 #define 更安全、更现代的常量定义方式。


3 #define 与 const 的区别

        在 1.2 节中,我们将常量分为了 “字面量常量” 和 “符号常量”。

        而在第 2 节中,我们学习了定义符号常量的两种主要方法:#define 和 const。

        一个初学者可能会问:既然两种都能用,我该用哪个?

        答案是:在 C 语言中,定义常量时应优先使用 const。

        为了理解为什么 const 是更优、更现代的选择,我们必须深入理解它们在 C 语言机制上根本不同的 “恩怨情仇”。

3.1 核心区别:处理时机

        #define 和 const 最根本的区别在于它们在程序 “编译过程” 中的处理时机完全不同。

  1. #define(宏常量):在 “预处理” 阶段处理。
    1. 预处理器是一个在编译器 “开工” 之前运行的工具。
    2. 它的工作非常简单粗暴:纯文本替换。
    3. 正如我们在 2.1 节用 gcc -E 验证的,预处理器会把代码中所有的 PI 替换为 3.14。当替换完成后,PI 这个名字本身就消失了。
    4. 后续的编译器根本不知道 PI 曾经存在过,它只看到了 3.14。
  2. const(常变量):在 “编译” 阶段处理。
    1. 编译器 “开工” 时,它会完整地看到 const double PI = 3.14; 这条 C 语句。
    2. 编译器会理解:哦!这是一个 C 语言的 const 关键字,用户定义了一个 double 类型的 “只读变量”,它的名字叫 PI,它的值是 3.14。
    3. PI 这个名字会一直存在于编译过程中,并被存入 “符号表”(Symbol Table)中。

        结论:#define 是编译前的 “全局查找替换”,而 const 是编译中的 “语法规则处理”。

3.2 区别二:类型安全

        由 “处理时机” 的不同,直接引出了两者在 “安全性” 上的巨大差异。

  1. #define:没有类型,不安全。
    1. 预处理器只管 “替换”,它根本不懂 C 语言的 “数据类型”。
    2. area = PI * r * r; 被替换为 area = 3.14 * r * r;
    3. 如果你写错了 #define PI "Hello"(一个字符串),预处理器也会忠实地替换,代码会变成 area = "Hello" * r * r;,直到编译器看到这行代码时,才会报出一个莫名其妙的 “字符串不能做乘法” 的错误。
  2. const:有明确类型,安全。
    1. const double PI = 3.14; 这行代码明确告诉了编译器:PI 是一个 double 类型。
    2. 如果你试图写 const double PI = "Hello";,编译器在编译阶段就会立刻报错,告诉你类型不匹配。
    3. 这种在编译期就能发现错误的能力,就是 “类型安全”(Type Safety)。

        结论:const 提供了强大的类型检查,能帮你提前发现错误,而 #define 不行。

3.3 区别三:内存占用与作用域

  1. #define:不占内存,没有作用域。
    1. 因为在预处理后 PI 这个名字就没了(只剩下 3.14),所以 PI 本身不占用内存。
    2. #define 也没有 C 语言 “作用域” 的概念。它从被定义的那一行开始,到文件结束(或被 #undef)都有效。
  2. const:占用内存,遵守作用域。
    1. const 定义的是一个 “只读变量”。是变量,就要占用内存空间。
    2. 因为它是 C 语言的变量,所以它完全遵守 C 语言的作用域规则。例如,定义在函数内部的 const 变量,只有在该函数内部才有效,这提供了更精细的控制。

        结论:const 占用内存(有地址),但换来了更灵活、更安全的 “作用域” 控制。

3.4 区别四:表达式求值

        这是我们在 2.2 节演示过的 “陷阱” 和 “优势” 的最直接对比。

  1. #define:仅替换,不求值。
    1. #define VAL 3 + 2
    2. result = VAL * 2;
    3. 预处理替换为 result = 3 + 2 * 2;
    4. 编译器计算为 3 + 4 = 7。(错误!)
  2. const:先求值,再赋值。
    1. const int VAL = 3 + 2;
    2. 编译器在处理这行代码时,会先计算 3 + 2,得到 5。
    3. 然后将 5 存入 VAL 这个只读变量中。
    4. result = VAL * 2;
    5. 编译器计算为 5 * 2 = 10。(正确!)

3.5 总结:#define 与 const 对比表

        为了方便你复习,我将以上所有区别总结为下表:

对比项#define 宏常量const 常变量(只读变量)
处理时机预处理阶段编译阶段
本质纯粹的文本替换有类型的只读变量
类型安全无(不进行类型检查)有(编译器进行类型检查)
内存占用不占用(仅在代码中展开)占用(有自己的内存地址)
语法无需分号,无需类型需要分号,需要指定类型
作用域文件作用域(或到 #undef)C 语言的块作用域(随定义位置)
表达式求值不求值(易引发优先级错误)先求值(再赋值给常量,安全)
调试不友好(符号表中没有宏名)友好(符号表中有变量名)

4 编程练习与巩固

4.1 使用 #define 计算总工资

        编写一个 C 程序,实现以下功能:

  1. 使用 #define 定义一个整型常量 HOURLY_WAGE(时薪),并赋值(例如 25);
  2. 提示用户从键盘输入一个整数 hours_worked(工作小时数);
  3. 计算总工资 total_pay(HOURLY_WAGE * hours_worked);
  4. 清晰地输出时薪、工作小时数和最终的总工资。

        通过本练习,你将巩固 #define 的基本用法,并理解它在定义固定费率(如时薪)以提高代码可维护性时的作用。

#include <stdio.h> // 引入标准输入输出库

// 假设一个固定的时薪(Hourly Wage)
// 使用 #define 定义这个 “配置项”
// 如果以后时薪调整了(比如从 25 涨到 30),
// 只需要修改这一行代码即可。
#define HOURLY_WAGE 25

int main(void)
{
    int hours_worked; // 声明一个整型变量,用于存储工作小时
    int total_pay;    // 声明一个整型变量,用于存储总工资

    // 1. 提示用户输入
    printf("请输入您的工作小时数 (整数): ");
    scanf("%d", &hours_worked); // 读取用户输入的整数

    // 2. 使用 #define 的常量进行计算
    // 预处理器会把这行代码替换为:
    // total_pay = 25 * hours_worked;
    total_pay = HOURLY_WAGE * hours_worked;

    // 3. 输出结果
    printf("--- 工资计算结果 ---\n");
    printf("您的时薪是: %d 元/小时\n", HOURLY_WAGE);
    printf("您工作了: %d 小时\n", hours_worked);
    printf("您的总工资是: %d 元\n", total_pay);

    return 0; // 程序正常结束
}

        程序运行结果如下(环境:Win64 + VS Code + MinGW-w64 UCRT):

4.2 使用 const 计算圆的面积和周长

        编写一个 C 程序,实现以下功能:

  1. 使用 const double 定义一个常量 PI,值为 3.14159;
  2. 提示用户从键盘输入一个 double 类型的半径 radius;
  3. 计算并输出该圆的面积(Area = π * radius²)和周长(Perimeter = 2 * π * radius);
  4. 确保输出的面积和周长结果保留两位小数。

        通过本练习,你将巩固 const 关键字的正确用法以及 double 浮点数的计算。

#include <stdio.h> // 引入标准输入输出库

// 在 main 函数外部(全局)定义 const 常量 PI
// 这样在本文件的所有函数中都可以安全地使用它
const double PI = 3.14159;

int main(void)
{
    // 1. 声明需要的变量
    double radius;    // 用于存储用户输入的半径
    double area;      // 用于存储计算出的面积
    double perimeter; // 用于存储计算出的周长

    // 2. 提示用户输入,并使用 scanf 读取
    printf("请输入圆的半径 (radius): ");
    scanf("%lf", &radius); // 注意:读取 double 类型需要使用 %lf

    // 3. 计算面积和周长
    area = PI * radius * radius;
    perimeter = 2 * PI * radius;

    // 4. 输出结果,使用 %.2lf 格式化到小数点后两位
    printf("圆的面积 (Area) 是: %.2lf\n", area);
    printf("圆的周长 (Perimeter) 是: %.2lf\n", perimeter);

    return 0; // 程序正常结束
}

        程序运行结果如下(环境:Win64 + VS Code + MinGW-w64 UCRT):

更多推荐