C语言学习(三)预处理

本文详细介绍了C语言中的预处理器、预处理指令、宏定义(包括全局、局部和条件作用域)、条件编译(#if、#ifdef、#ifndef等)、文件包含、错误处理(#error)、行号重置(#line)以及#pragma预处理指令,展示了如何使用这些技术进行代码组织和优化。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

        在c语言中,预处理阶段是编译过程的第一步,关键角色是预处理器。预处理器是一个文本处理程序,它执行一些列操作来修改原始的C源代码文件。预处理的曹组基于以#开头的预处理指令,它们在编译器真正编译程序之前处理源代码。

        预处理是 C 语言编译过程的一部分,但技术上讲,预处理指令不是 C 语言标准语法的一部分。预处理器处理源代码中的预处理指令,然后将处理过的代码传递给编译器。这些预处理指令有助于文件包含、条件编译、宏定义等任务,都发生在编译之前。

        在 C 语言中,以 # 开头的行通常表示预处理指令。这些指令指示预处理器执行特定的操作,比如包含其他文件、定义宏、条件编译等。然而,并不是每一个包含 # 的行都是有效的预处理指令;如果 # 后面跟的不是有效的预处理命令,它将被视为错误。

        预处理指令不是 C 语言的语句,而是在编译之前由预处理器解释和执行的指令。因此,它们后面不需要分号;)。预处理指令的结尾是行末,不需要任何额外的终结符。

以下是c语言中的预处理指令:


宏定义#define
        宏定义的原理基于文本替换机制。当预处理器遇到#define指令时,他会将指定的宏名在后续代码中替换为定义的内容。这个替换过程发生在编译之前,因此对编译器来说,替换后的文本看起来就像是源代码的原始部分。
        #define定义宏常量可以出现在代码的任何地方;#define从本行开始,之后的代码都可以使用这个宏常量。宏常量的例子:

#define PI 3.14159
#define MAX_BUFFER_SIZE 512

字符串宏常量: 

#include <stdio.h>

#define GREETING "Hello, World!"

int main() {
    printf("%s\n", GREETING);  // 输出:Hello, World!
    return 0;
}

宏表达式
        通过定义宏来替代复杂的表达式或代码块,使代码更简洁。但是#define表达式给有函数调用的假象,却不是函数,只是简单的文本替换;#define表达式可以比函数更强大;#define表达式比函数更容易出错;
        注意事项:在定义宏表达式时,应将整个宏体以及参数用括号括起来,以防止宏展开后运算顺序发生改变导致意外的结果;示例如下:其中x,a,b都是参数,在实际代码中可以替换调用;

#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))

宏表达式与函数的对比分析:
        首先,宏表达式在预编译阶段被处理,编译器不知道宏表达式的存在;宏表达式用“实参”完全代替形参,不进行任何运算,在宏表达式中,参与运算的都是变量本身;宏表达式没有任何的“调用”开销;宏表达式中不能出现递归定义;
        函数,函数在编译时被编译成机器代码,每次调用时执行已编译的代码,具有类型安全检查;虽然函数调用有额外开销,但现代编译器的优化(如内联函数)可以减少这种开销,特别是对于小型函数;函数适合执行复杂的逻辑。代码内部结构清晰,易于阅读和维护。

宏定义的作用域
        1、全局作用域:如果宏在一个文件的顶部定义,在任何包含此文件的函数之前,那么它通常具有全局作用域。这意味着该宏在整个文件中,以及包含(通过#include)该文件的任何其他文件中都是可见的;
        2、局部作用域:宏可以在一个文件中的特定位置定义,其作用域从定义点开始,到文件结束或者遇到相应的#undef指令为止;通过使用#undef预处理指令,可以限定宏的作用域,使其在不再需要时停止作用;
        3、条件作用域:宏的定义可以被条件预处理指令如#ifdef#ifndef#else#endif所控制,使得宏的定义依赖于特定的编译条件。
 

#undef 用法:#undef指令用于撤销已定义的宏;示例:

#define DEBUG_PRINT(msg) fprintf(stderr, "Debug: %s\n", msg)
...code
#undef DEBUG_PRINT

日志宏
        在C语言中,日志宏通常是指一组用于输出调试信息或程序运行状态的预处理宏定义,它们在开发、调试和维护软件时起到非常重要的作用。作用是在开发阶段提供详细的执行信息,帮助开发者理解程序行为或定位错误。
        日志宏通常根据日志级别和输出需求来设计。
示例一:

#include <stdio.h>

// 日志级别定义
#define LOG_LEVEL_DEBUG 1
#define LOG_LEVEL_INFO  2
#define LOG_LEVEL_WARN  3
#define LOG_LEVEL_ERROR 4

// 当前日志级别
#define CURRENT_LOG_LEVEL LOG_LEVEL_DEBUG

// 日志宏定义
#define LOG_DEBUG(format, ...) \
    if (CURRENT_LOG_LEVEL <= LOG_LEVEL_DEBUG) \
        fprintf(stderr, "DEBUG: " format "\n", ##__VA_ARGS__)
#define LOG_INFO(format, ...) \
    if (CURRENT_LOG_LEVEL <= LOG_LEVEL_INFO) \
        fprintf(stderr, "INFO: " format "\n", ##__VA_ARGS__)
#define LOG_WARN(format, ...) \
    if (CURRENT_LOG_LEVEL <= LOG_LEVEL_WARN) \
        fprintf(stderr, "WARN: " format "\n", ##__VA_ARGS__)
#define LOG_ERROR(format, ...) \
    if (CURRENT_LOG_LEVEL <= LOG_LEVEL_ERROR) \
        fprintf(stderr, "ERROR: " format "\n", ##__VA_ARGS__)

调用:

int main() {
    LOG_DEBUG("This is a debug message: %s", "Sample debug");
    LOG_INFO("System is up and running");
    LOG_WARN("Warning: Low memory");
    LOG_ERROR("Error: File not found");

    return 0;
}

除此以外,在c语言中,有以下预定义宏:

可以根据上述这几个宏定义来编写一个宏日志 :运行时将输出包含时间戳、文件名、行号和自定义消息的日志信息;
示例二:使用do { ... } while (0)结构来确保宏的使用像一个语句一样安全,即使在条件语句中也不会出现问题

#include <stdio.h>
#include <time.h>

#define LOG_MSG(format, ...)                                         \
    do {                                                             \
        time_t now = time(NULL);                                     \
        struct tm *now_tm = localtime(&now);                         \
        char buf[20];                                                \
        strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", now_tm);     \
        fprintf(stderr, "[%s] %s:%d: " format "\n", buf, __FILE__, __LINE__, ##__VA_ARGS__);                                                      \
    } while (0)

调用:

int main() {
    LOG_MSG("This is a log message.");
    LOG_MSG("Here is a number: %d", 42);

    return 0;
}

输出信息:

[2024-04-22 12:34:56] example.c:6: This is a log message.
[2024-04-22 12:34:56] example.c:7: Here is a number: 42

条件编译

        条件编译的功能使得编译器可以按照不同的条件去编译不同的程序部分,因而产生不同的目标代码文件。条件编译的行为类似于c语言中的if...else;条件编译是预编译指示命令,用于控制是否编译某段代码。#if...#else...#endif被预编译器处理;而if...else语句被编译器处理,后者是会被编译进目标代码的。

条件编译的三种形式:

        第一种形式,类似C语言中的if-else语句,但它们在预处理阶段执行。示例:

#define VERSION 2

#if VERSION == 1
    printf("Version 1 of the code\n");
#elif VERSION == 2
    printf("Version 2 of the code\n");
#else
    printf("Unknown version\n");
#endif

   #ifdef用于检查某个宏是否已定义,如果已定义,则编译随后的代码。#ifndef则相反,它用于检查某个宏是否未被定义,如果未定义,则编译随后的代码。示例:

#define DEBUG

#ifdef DEBUG
    printf("Debugging is enabled.\n");
#endif

#ifndef RELEASE
    printf("Release mode is not defined.\n");
#endif

        虽然#undef不直接控制条件编译,但它与条件编译指令结合使用时可以非常有用,因为它可以取消宏的定义,从而影响后续的#ifdef#ifndef条件。示例:

#define DEBUG
#undef DEBUG

#ifdef DEBUG
    printf("Debugging is still enabled.\n");
#else
    printf("Debugging has been disabled.\n");
#endif

条件编译与头文件保护 :     
        在c语言项目中,包含多个头文件时,头文件之间的相互包含往往会引起所谓的“重复包含”问题,这可能导致编译错误,如重复定义的变量、类型或者函数等等。为了解决这个问题,通常使用条件编译结合头文件保护(Header Guards)的方式来防止头文件被重复包含。

头文件保护:
        头文件保护是通过定义一个唯一的宏(通常是与文件名相关的大写字母组合)来实现的。在头文件的开始定义这个宏,如果宏已定义,则预处理器会跳过整个头文件的内容;如果未定义,则定义它,并继续处理头文件内容。这种方法确保无论头文件被包含多少次,其内容都只会在编译过程中被处理一次。
        编写步骤:1、在每个头文件的开始,使用#ifndef来检查一个唯一的宏是否已经定义。如果没有定义,使用#define来定义它;2、在头文件的结尾使用#endif来结束条件编译。

示例:给出一个a.h文件和b.h文件,运用头文件保护:

#ifndef A_H   // 检查宏A_H是否已定义
#define A_H   // 如果未定义,则定义宏A_H

#include "b.h" // 包含b.h

struct A {
    int value;
    struct B* b_ptr; // 使用来自b.h的结构体B
};

#endif /* A_H */
#ifndef B_H   // 检查宏B_H是否已定义
#define B_H   // 如果未定义,则定义宏B_H

#include "a.h" // 包含a.h

struct B {
    double value;
    struct A* a_ptr; // 使用来自a.h的结构体A
};

#endif /* B_H */

注意事项:
1、宏命名约定:为了避免命名冲突,通常使用头文件全名转换为大写并加上后缀 _H 作为保护宏的名称;
2、项目中统一应用:在所有头文件中应用相同的头文件保护方法,可以避免潜在的头文件包含问题,确保项目的健壮性。


文件包含:#include
        当预处理器发现#include指令时,会查看后面的文件名,并把文件的内容包含到当前文件中,及替换源文件中#include指令。这相当于把包含文件的全部内输入到源文件#include指令所在的位置。#include有两种使用格式:
        #include <filename>        尖括号表示预处理到系统规定的路径中去获得这个文件
        #include "filename"          双括号表示预处理到当前目录中查找文件名,若没有找到,则按系统指定的路径信息所搜其他目录。
注:#include是将已经存在的文件嵌入到当前文件中,并且include支持相对路径。


#error 预处理
        #error预处理指令做作用是,编译程序时,只要遇到#error就会生成一个编译错误提示消息,并且停止编译。

        语法格式:#error error-message


#line 预处理
        #line 的作用是改变当前行数的文件名称。用于指定新的行号和编译文件名,并对源程序的代码重新编号。

        语法格式:#line number["filename"]


#pragma 预处理
        #pragma是编译器指示字,用于指示编译器完成一些特定的动作;#pragma所定义的很多指示字都是编译器和操作系统特有的;#pragma在不同的编译器间是不可移植的。

        语法格式:#pragma parameter
不同的parameter参数语法和意义不相同。

1、#pragma message
        message参数:能够在编译信息输出的窗口中输出相应的信息,用于在编译过程中插入调试信息或提醒。用法是:
        #pragma message("文本信息")
示例:

#if !defined(_WIN32)
#pragma message("Note: This module is only tested thoroughly on Windows platforms.")
#endif

2、#pragma pack
        内存对齐:内存对齐是计算机系统中用于提高内存访问效率的一种数据存储技术。它确保数据结构的起始内存地址按照某些预定的边界对齐,通常是几个字节(如2、4、8等)

示例:结构体内存对齐:

struct Example {
    char a;    // 占用1字节
    int b;     // 占用4字节
    short c;   // 占用2字节
};

如果不考虑内存对齐,这个结构体可能被紧凑地存储,如下:

| a | b0 | b1 | b2 | b3 | c0 | c1 |

        大多数编译器默认会自动对结构体进行内存对齐,以确保每个字段根据其类型对齐。对于上述结构体,编译器可能会在ab之间插入填充字节,以确保b在一个4字节边界上开始:

| a |   |   |   | b0 | b1 | b2 | b3 | c0 | c1 |

内存对齐计算方法详见书本。 

用法格式:#pragma pack(n) //n = 1,2,4,8...保存当前的对齐方式,设置按n字节对齐。


#运算符
        #运算符用于在预编译期将宏参数转换为字符串,也称为字符串化运算符。

用法格式:#define STRINGIZE(x) #x
当使用 STRINGIZE(someValue) 时,someValue 就会被转换成 "someValue"

示例:

#define PRINT_VAR(var) printf(#var " = %d\n", var)

int main() {
    int test = 10;
    PRINT_VAR(test); // 输出: test = 10
    return 0;
}

        在上述这个例子中, PRINT_VAR(test)会被宏展开成为:printf("test" " = %d\n", test);在C语言中,连续的字符串字面量会被自动拼接,所以 "test" " = %d\n" 会变成 "test = %d\n"


##运算符
        ##运算符在宏定义中也可以用于宏函数的替换部分。作用是吧两个语言符号组合成单个语言符号。
        用法格式:#define XXX(xxx)  str##xxx
        语句:XXX(tyu);        输出是:strtyu 

##运算符在实际编程中常用于创建复合的宏,如根据给定的参数生成特定的函数名或变量名。

示例:

typedef struct {
    int x;
    float y;
} Point;

typedef struct {
    double width;
    double height;
} Size;

// 定义用于生成访问函数声明的宏
#define DECLARE_GET_FUNC(struct_name, field_name, field_type) \
    field_type get_##struct_name##_##field_name(const struct_name* s) { \
        return s->field_name; \
    }

#define DECLARE_SET_FUNC(struct_name, field_name, field_type) \
    void set_##struct_name##_##field_name(struct_name* s, field_type value) { \
        s->field_name = value; \
    }

// 使用宏为Point和Size的字段生成函数声明
DECLARE_GET_FUNC(Point, x, int)
DECLARE_SET_FUNC(Point, x, int)

DECLARE_GET_FUNC(Size, width, double)
DECLARE_SET_FUNC(Size, width, double)

int main() {
    Point p = {10, 20.5f};
    Size sz = {100.0, 200.0};

    // 使用生成的函数访问和修改结构体字段
    printf("Point x: %d\n", get_Point_x(&p));
    set_Point_x(&p, 30);
    printf("Point x: %d\n", get_Point_x(&p));

    printf("Size width: %f\n", get_Size_width(&sz));
    set_Size_width(&sz, 300.0);
    printf("Size width: %f\n", get_Size_width(&sz));

    return 0;
}

        在上面的代码中,DECLARE_GET_FUNCDECLARE_SET_FUNC 宏使用了##运算符来连接函数名。这样,就可以为不同类型的结构体生成标准化的获取和设置函数,而不需要为每个字段手写这些函数。这种技术可以减少重复代码,使得维护和更新变得更加简单。当有新的结构体字段添加时,只需要再次使用宏即可。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值