明明包含了头文件,为何还是显示未定义错误?

今天有个同事找到我,说发现了一个很神奇的问题,编译代码提示了未定义错误,但是明明包含了对应的头文件,而且查了好几遍,确认不存在包含错的问题,但就是编译失败,都怀疑是编译器 BUG 了!

首先本着严谨的态度,不能说编译器不存在 BUG,但从实际角度来看,我们使用的编译器都是稳定发布版,而且用的都是最基本的功能,且未定义错误算是最基本的错误类型了,这种情况下出 BUG 的概率可以说微乎其微,所以还是让同事打开他的代码看一看。

不看不知道,一看 … 就很明了了!实际上这个问题很典型,很多老工程师在开发一些比较复杂的项目时如果稍不留神也会出现这种问题,不过只要你了解了问题的原因,下次再出现时就能很快定位到问题点并解决。

我们今天就来看看这到底是个啥问题!

首先我们准备几个代码文件,分别为 a.ca.hb.h

/** 
 * a.c
 * 
**/

#include <stdio.h>
#include "a.h"
#include "b.h"

int main(void) {
    File1Struct file1;
    File2Struct file2;

    file1.data = 100;

    file1.ptr = &file2;  // 使用 b.h 中的结构体
    file2.ptr = NULL;    // 初始化指针

    printf("File1 data: %d\n", file1.data);

    return 0;
}

/** 
 * a.h
 * 
**/

#ifndef A_H
#define A_H

#include "b.h"

typedef struct _File1Struct {
    int data;
    File2Struct *ptr;
} File1Struct;


#endif  // A_H

/** 
 * b.h
 * 
**/

#ifndef B_H
#define B_H

#include "a.h"

typedef struct _File2Struct {
    int value;
    File1Struct *ptr; 
} File2Struct;

#endif  // B_H

代码很简单,我们也不急着分析,先编译一下看看:

jay@jaylinuxlenovo:~/test/code$ gcc a.c -o app
In file included from a.h:9,
                 from a.c:7:
b.h:13:5: error: unknown type name ‘File1Struct’
   13 |     File1Struct *ptr;
      |     ^~~~~~~~~~~

可以看到编译报错了,报错位置是 b.h 的第 13 行,也就是下面这条代码:

File1Struct *ptr; 

而这条代码是包含在结构体 File2Struct 中的一个成员:

typedef struct _File2Struct {
    int value;
    File1Struct *ptr; 
} File2Struct;

错误信息的含义是 “未知的类型名 File1Struct”,根据一开始给出的代码文件,我们知道 File1Struct 是定义在 a.h 中的一个结构体:

typedef struct _File1Struct {
    int data;
    File2Struct *ptr;
} File1Struct;

所以第一个想到的就是没有包含 a.h 头文件,然而我们再回看 b.h 这个文件,明明是在开头就包含了 a.h:

/** 
 * b.h
 * 
**/

#ifndef B_H
#define B_H

#include "a.h" //<----  这里明明包含了 a.h

typedef struct _File2Struct {
    int value;
    File1Struct *ptr; 
} File2Struct;

#endif  // B_H

明明包含了头文件,但却找不到头文件所包含头文件中定义的类型!你说奇不奇怪?如果你还没摸清其中的道道,那觉得奇怪是必然的,甚至有些朋友在自己的项目中遇到某个文件突然报这个错时会觉得其他的文件不会出现这种问题,就这个文件会出现这种问题,难道是玄学?

当然不是,一点都不玄,甚至可以说这是个非常低级的错误!

如果仔细观察 a.hb.h 这两个头文件,就会发现一个很别扭的现象,即 a.h 中定义的结构体里面其中一个成员是 b.h 中定义的结构体,而 b.h 定义的这个结构体中又包含了 a.h 中定义的结构体,导致 a.h 包含了 b.h,而 b.h 又包含了 a.h !这就是典型的循环依赖!

我们知道在编译的 预处理 阶段,头文件中的内容会被直接插入到引用它的源文件中,此时就有意思了,a.h 中的内容会被插入到源文件,但 a.h 中又包含了 b.h,此时 b.h 的内容同样也会被插入,而 b.h 又包含了 a.h,这时候又开始插入 a.h 的内容,如此循环,无穷无尽,整个编译过程就会在预处理阶段进入死循环。

当然我们在编写头文件的时候都有个规则,就是要用如下代码包裹实际内容:

#ifndef XXX
#define XXX

// 文件内容

#endif  

这段代码就是用于避免循环依赖进入死循环情况的,其原理也很简单,就是告诉预处理器在插入过 XXX 后如果遇到同样的内容就不再插入,这样在插入了 a.hb.h 后就不会再插入 a.h 了。然而这个问题解决了,我们的主要问题就很显现出来了。此时按照这个逻辑,在预处理后我们的 a.c 就会变成下面这样(实际会有更多内容,这里只保留我们关心的):

/** 
 * a.c
 * 
**/

// 省略 stdio.h 的内容 ......

typedef struct _File2Struct {
    int value;
    File1Struct *ptr; 
} File2Struct;


typedef struct _File1Struct {
    int data;
    File2Struct *ptr;
} File1Struct;


int main(void) {
    File1Struct file1;
    File2Struct file2;

    file1.data = 100;

    file1.ptr = &file2;  // 使用 b.h 中的结构体
    file2.ptr = NULL;    // 初始化指针

    printf("File1 data: %d\n", file1.data);

    return 0;
}

很明显,File2Struct 定义在了 File1Struct 之前,自然在编译器解析 File2Struct 的成员类型 File1Struct 时它就是没有被定义过,当然这个与包含头文件的顺序有关,不同的顺序也会导致出现 File1Struct 定义在 File2Struct 之前的情况,此时就会报 File2Struct 未定义错误。

当把文件内容展开时这个问题变得显而易见,但大多数时候这种问题都是在一些比较复杂的工程结构下,动辄几十甚至上百的文件,且刚编写的时候大概率不会有问题,但往往在后期维护过程中修修补补,由于需求的增加需要在某个模块中加入另一个模块的接口,此时一不留神加了个会导致循环依赖的头文件,进而出现编译报警,此时所有文件自身内容实际都是没问题的,如果不特别留神头文件的包含关系,你就很难发现错误的原因!

问题找到了,怎么解决?

1 抽取公共内容到一个公共头文件

这种方法很好理解,既然 a 要用到 b 的内容,b 要用到 a 的内容,那我就搞一个 c,把 a 和 b 都需要的内容写到 c 中,然后 a 和 b 都包含 c 就行了,这种方式从源头就杜绝了循环依赖的出现,优化了代码的结构设计,也是最推荐的方式。

那如果我就是想要 a 包含 b,b 包含 a,还想要能编译成功,可行吗?

也可行!

2 使用前向声明

前向声明(Forward Declaration)是指在代码中仅声明某个类型或结构的存在,而不提供完整的定义。它允许代码引用某个类型(如结构体、类或函数),但推迟对其具体内容的定义。

基于这种方式,编译器就能在没有获取到某个类型的具体定义情况下先不进行报错,只要后续能找到具体的定义,编译就能成功:

/**
 * b.h
 *
 **/

#ifndef B_H
#define B_H

#include "a.h"

typedef struct _File1Struct File1Struct;  // 前向声明

typedef struct _File2Struct {
    int value;
    File1Struct *ptr;
} File2Struct;

#endif  // B_H

此时我们再尝试编译:

jay@jaylinuxlenovo:~/test/code$ make
gcc -c a.c
gcc -o app a.o 
jay@jaylinuxlenovo:~/test/code$ ./app 
File1 data: 100
jay@jaylinuxlenovo:~/test/code$ 

可以看到编译没有错误,最终程序也能正常运行,问题得到了解决。

最后还是要强调一下,虽然前向声明可以解决我们的问题,也可以精简代码与文件量,但一个稳定的系统一定是从设计层面就避免了类似的情况发生,而不是在发生后再通过某种方式进行补救。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

WKJay_

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值