news 2026/10/2 3:14:30

C/C++条件编译完全指南:从#ifdef到#endif的实战技巧

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C/C++条件编译完全指南:从#ifdef到#endif的实战技巧

如果你在代码里碰到过#ifdef、#define、#else、#endif这四个兄弟,多半已经被它们搞得有点头大。尤其是当你从网上复制了一段带条件编译的代码,或者接手一个跨平台的老项目时,这四个指令总会以各种方式出现在你面前。最近甚至有人在 Linux 终端里贴了一段代码,屏幕冒出else?的提示,来问我是不是代码写错了。其实问题往往不在代码本身,而在于你对这组预处理指令的理解还停留在“好像见过”的层面。

这篇文章我想把#ifdef这组条件编译指令讲透,不讲废话,直接进入正题。你会看到它们各自的职责、配合逻辑、真实应用场景,以及我这些年踩过的坑。无论你是刚开始学 C/C++ 的新手,还是被项目里一堆宏开关折磨过的老手,这篇都值得你花十分钟读完。

1. 条件编译的本质:四个预处理指令的分工与生效时机

1.1 预处理阶段到底做了什么

要理解#ifdef,先要理解“编译”这件事不是一步到位的。C/C++ 源码要经过预处理、编译、汇编、链接四个阶段,而#开头的指令统统属于第一阶段:预处理。

预处理的工作说白了就是“文本编辑”。编译器拿到源文件后,第一件事不是去检查语法,而是先把所有#开头的指令处理掉。#include会把头文件内容整个复制进来,#define会做文本替换,#ifdef则决定哪些文本保留、哪些文本丢弃。等预处理完成,真正的编译器看到的是一份已经被“筛选过”的干净代码。

这个过程你可以想象成写文章时的修改环节:你先写了一大段内容,然后在上面标注“这段发表时删掉”“那段只在某个版本里保留”。预处理指令就是这些标注规则,编译器在正式阅读你的文章之前,先把不用的段落划掉。

1.2 四个指令各自的角色定位

这四个指令是一个完整的条件判断结构,缺少任何一个都不完整。

指令作用典型形态
#define定义一个宏,作为条件判断的依据#define DEBUG
#ifdef如果某个宏已被定义,则保留后续代码#ifdef DEBUG
#else与#ifdef搭配,处理“未定义”的情况#else
#endif结束整个条件编译块#endif

我见过很多新手把#define和普通变量定义搞混,这是个大误区。宏不是变量,它没有类型,也不占内存。#define DEBUG只是告诉预处理器“在后续代码里,凡是出现 DEBUG 这个词的地方,就把它替换成空”。更准确地说,宏在预处理完成后甚至不存在了,它只活在预处理阶段。

而#ifdef做的事更加纯粹:它只关心一个宏“被定义了没有”,完全不关心宏的值是什么。哪怕你写的是#define DEBUG 0,在#ifdef DEBUG眼里,这个宏依然是“已定义”的,条件成立,代码会被保留。这一点特别容易让人踩坑,后面我会专门讲。

1.3 条件编译和 if 语句是两码事

这是理解这组指令最关键的一点。很多人觉得#ifdef和if差不多,其实两者有本质区别。

if是运行时判断,代码的每个分支都会被编译进最终程序里,只是运行时根据条件决定执行哪一段。换句话说,两个分支的代码都存在于可执行文件中,只是其中一个不运行而已。

#ifdef是预处理时判断,只有满足条件的那段文本会进入编译流程,不满足分支的代码在预处理阶段就被直接丢弃了。最终的可执行文件里,压根不会有被裁掉的代码。

我举个例子你就明白了:

if (0) { int x = ; // 这里少了个数字,语法错误 }

这段代码即使if (0)永远不可能执行,编译器仍然会报错,因为语法检查针对的是所有代码。

但如果换成:

#if 0 int x = ; // 这里少了个数字 #endif

编译器完全不会报错,因为#if 0把这段代码在预处理阶段就删掉了,编译器压根看不到它。

这个特性非常有用,尤其是你需要临时屏蔽一大段代码的时候。很多人用/* */注释,但注释不能嵌套,被注释代码里如果还有*/就完蛋了;而#if 0屏蔽代码块的方案永远不会出错,所以老手都喜欢用#if 0来临时禁用代码。

2. 四个高频实战场景:防重复包含、平台适配、调试开关与特性裁剪

2.1 头文件防重复包含:每个头文件都要有的“三道门”

如果你打开过任何一个正规的 C/C++ 项目头文件,大概率见过这种结构:

#ifndef MY_HEADER_H #define MY_HEADER_H /* 头文件的真实内容 */ #endif

这三行就是防重复包含的标准写法,行业内叫 include guard。为什么需要它?因为一个大型项目里有几十上百个头文件,它们之间经常互相包含。A 头文件包含 B,B 又包含 A,或者两个源文件通过多条路径把同一个头文件包含进来,如果没有这个机制,同一个结构体、同一个函数声明就会在编译时出现多次定义,直接报错。

原理很简单:第一次包含某个头文件时,MY_HEADER_H还没有被定义,所以#ifndef为真,进入代码块,紧接着#define MY_HEADER_H把它标记为“已处理”。等这个头文件第二次被包含时,#ifndef MY_HEADER_H发现宏已经存在,整个代码块直接跳过,里面的结构体声明就不会重复出现了。

这里有个细节值得注意:MY_HEADER_H这个名字是任意的,但行业惯例是使用头文件名的大写形式加上下划线。这样一看到宏名,就能反推出它是哪个头文件的守卫。如果项目里重名了,就会出现“第二个头文件被静默跳过”的诡异故障,后面讲排查案例时细说。

2.2 跨平台与编译器差异:一套代码同时适配 Windows 和 Linux

这是条件编译最经典的应用场景。你的代码要同时跑在 Windows 和 Linux 上,但两个平台提供的 API 不一样,头文件名字不一样,某些类型定义也不一样。如果分别维护两份源代码,改一处需求要同步改两处,迟早会出事故。正确的做法是:一套代码,用条件编译自动适配。

实际项目里往往长这样:

#ifdef _WIN32 #include <windows.h> #define PATH_SEP '\\' #else #include <unistd.h> #define PATH_SEP '/' #endif

_WIN32这个宏不是我们定义的,而是 Windows 平台的编译器在编译时自动定义的。同理,Linux 下的 GCC 会自动定义__linux__,macOS 下会定义__APPLE__。利用这些预定义宏,你就能写出一份在三个平台上都能正确编译的代码。

除了平台差异,编译器差异也经常用到条件编译。比如某些编译器支持#pragma once,某些编译器支持__attribute__((deprecated)),你可以在代码里判断编译器版本,再决定用哪种语法:

#ifdef __GNUC__ #define DEPRECATED __attribute__((deprecated)) #else #define DEPRECATED #endif

这样业务代码里只需要统一使用DEPRECATED宏,编译器换不换都无所谓,定义处负责处理差异。

2.3 调试日志与发布版本切换:一套代码跑开发模式和生产模式

我早期做项目时,经常在交付前手动删除调试用的printf,后来发现这样做简直是灾难:删掉的代码过几天又要加回来,加回来又忘了哪几行。直到我学会用条件编译控制日志输出,整个人都清爽了。

推荐的写法是这样:

#ifdef DEBUG #define LOG(fmt, ...) printf("[DEBUG] " fmt "\n", ##__VA_ARGS__) #else #define LOG(fmt, ...) do {} while (0) #endif

开发阶段用gcc -DDEBUG编译,所有日志正常输出;发布阶段不加-DDEBUG,日志代码被预处理为“什么都不做”,一行业务代码都不用改。

这里特别强调一件事:很多人以为-DDEBUG会把宏的值设成 1,其实-DDEBUG等价于#define DEBUG,宏的定义里没有值,但这已经足够让#ifdef DEBUG成立了。如果你想定义带值的宏,需要用-DDEBUG=1这种写法。

用宏控制日志还有另一个优势:发布版本中日志代码彻底不存在,而不是“被 if 跳过”。这不仅能减少日志输出的性能损耗,还能防止调试信息泄露到生产环境中。我在实际项目里见过把 SQL 查询语句打到线上日志的惨案,就是因为用了运行时判断而不是预处理裁剪。

2.4 版本特性开关:快速裁剪功能模块

除了平台和调试,条件编译还是功能特性管理的好帮手。我在做嵌入式项目时,同一套源文件要出三四个型号的产品,有些型号带蓝牙模块,有些带摄像头,有些两个都带。如果为每个型号复制一份代码,后续修 bug 要重复操作好几次。用特性宏就方便得多:

#define FEATURE_BLUETOOTH #define FEATURE_CAMERA #ifdef FEATURE_BLUETOOTH #include "bluetooth.c" #endif #ifdef FEATURE_CAMERA #include "camera.c" #endif

从产品中裁剪一个功能,只改一行#define,甚至可以通过编译器的-D参数在构建系统里控制,连源代码都不用动。现代大型项目里甚至发展出一整套构建系统级别的特性管理,Linux 内核的 Kconfig 就是最典型的例子。所有的内核配置选项,本质上就是一系列宏开关,编译时由条件编译指令决定哪些模块进内核,哪些模块不编译。

这里必须提醒一句:特性开关如果滥用,代码里会全是#ifdef,可读性急剧下降。这个度怎么把握?我的经验是:最多嵌套三层,超过三层一定要拆文件或者重构。三层以上的条件编译嵌套,你在三个月后回来看,自己都未必记得每个分支对应什么组合。

3. 排查实录:条件编译让我半夜挠头的四个坑

3.1 坑一:宏定义位置不对,整个模块被静默裁掉

有次我帮同事排查一个诡异问题:他新写的功能函数编译时根本没报错,但运行时怎么调用都提示“未定义引用”。我让他把预处理后的文件输出出来一看,发现他定义的那个函数压根就不存在于编译单元中。

问题出在他把#define ENABLE_NEW_FEATURE放在了头文件里,而包含这个头文件的源文件里写的是:

#include "config.h" #ifdef ENABLE_NEW_FEATURE void new_feature() { ... } #endif

看起来没问题对吧?但问题在于 config.h 里被另一个宏包裹了:

// config.h 内部 #ifndef USE_PROD_CONFIG #define ENABLE_NEW_FEATURE #endif

而构建命令里恰好定义了USE_PROD_CONFIG,导致ENABLE_NEW_FEATURE从未被定义,整个函数在预处理阶段就消失了。编译链接却没有任何报错,只有到链接阶段才会提示找不到符号。

这件事给我的教训是:宏定义和条件判断的位置关系,比代码的执行顺序还要重要。宏是否存在,只取决于它在预处理文本中出现的先后顺序,与 C 语言的“作用域”概念完全不搭界。排查这类问题,第一步永远是看预处理输出,确认代码是否还活着。

3.2 坑二:#else 和 #endif 不配对,嵌套条件编译的“就近匹配”陷阱

条件编译可以嵌套,但嵌套带来的代价就是配对关系容易出错。C 语言里{}你不配对编译器立刻报错,但#ifdef和#endif如果不配对,报错信息往往很晚才出现,甚至会指向文件最后一行。

设想一下:

#ifdef A #ifdef B int x; #else int y; #endif

这里面#ifdef B和#else配对没问题,但#ifdef A只开不关,缺少一个#endif。预处理器处理到文件末尾时才发现问题,但编译器报错位置通常就在末尾附近,如果你在特别长的文件里,很难第一时间想到是头部某个条件编译块没闭合。

应对方法有两种,都是我在实际项目里常用的:

一是缩进对齐,#ifdef和#endif保持同样的缩进层级,嵌套时缩进加一层,这样一眼就能看出配对关系。

二是给#endif加注释:

#ifdef A #ifdef B int x; #else int y; #endif /* B */ #endif /* A */

这是 Linux 内核代码里的标准风格,每条#endif都配上所属的条件宏注释。虽然多打几个字,但在复杂条件编译结构里,这套习惯能救你的命。我见过太多人出现配对问题后,发明了各种奇怪的方法去“调试”预处理器,其实源头就是一行注释的事。

3.3 坑三:宏名和变量名撞车后的“诡异报错”

另一个让我记忆深刻的问题,是有人定义了一个宏#define STATUS 0,然后在函数里写:

int status = get_status(); printf("%d\n", status);

结果编译器疯狂报错。原因很简单:status这个变量名在预处理时被替换成了0,代码实际变成了:

int 0 = get_status(); printf("%d\n", 0);

这当然不可能编译通过。但报错信息的指向非常迷惑,因为它标出的位置是真实的源码行,你盯着源码看半天也看不出问题,因为问题出在预处理之后,而不是你看到的代码本身。

这种问题在大型项目里非常隐蔽,尤其是别人定义了一个通用名词的宏,你完全不知道。排查方法是让编译器输出预处理后的代码,一眼就能看出是宏展开了。从根本上讲,项目里定义宏时要有前缀约定,比如APP_STATUS_OK而不是STATUS_OK。C 语言里宏没有作用域的概念,一旦定义,在当前文件之后的全部位置有效,所以任何全大写但不带命名前缀的宏,都可能是定时炸弹。

3.4 坑四:终端贴入带 # 的代码,屏幕冒出 “else?”

聊回文章开头那个场景。有读者说他在 Linux 终端里贴了一大段代码,结果屏幕上显示出else?的提示,问是不是原来的代码有问题。

这里要理清一个概念:终端本身也是一个程序,它把粘贴进来的文本当作键盘输入。如果这些文本里有特殊字符,终端模拟器会直接解释执行。最常见的情况是:粘贴的代码里有#开头的行,而#在某些 shell 里是注释开头,后面的内容被当作注释吞掉了。更麻烦的是,如果粘贴内容里包含了未闭合的反引号或者双引号,shell 会认为输入还没有结束,一直等待下一个匹配的引号,你贴进去的代码就被整体当成字符串的一部分。

遇到这种情况,我的建议是:不要直接把代码贴进交互式终端,而是先用编辑器打开一个文件,把代码粘贴进去,再执行编译命令。如果非要直接编译,也至少打开cat > test.c << 'EOF'这种格式,用单引号包住 EOF,阻止 shell 对粘贴内容做任何变量展开和特殊字符解释。

如果已经出现了else?之类的提示,说明 shell 当前处于某种未闭合的输入状态。按Ctrl + C终止,然后重新来。记住一个原则:终端里看到的“异常输出”,很多时候不是代码的问题,而是你的粘贴操作绕过了编辑器对内容的保护机制。

4. 进阶用法:同族指令、特殊符号与工程级安全约定

4.1 #ifdef 和 #if defined() 的取舍

如果你只会写#ifdef,很快会遇到一个局限:只能判断“一个宏是否定义”。当条件变成“A 宏定义了且 B 宏也定义了”,#ifdef就不够用了。

这时候要用#if和defined()的组合:

#if defined(DEBUG) && defined(TRACE) /* 只有 DEBUG 和 TRACE 都定义时,才保留这段代码 */ #endif #if defined(DEBUG) && !defined(RELEASE) /* DEBUG 定义且 RELEASE 未定义时 */ #endif

defined()是一个预处理运算符,放在宏名外面,如果宏已定义则返回真,否则返回假。它比#ifdef更灵活,可以做任意逻辑组合。行业惯例是:单个宏判断用#ifdef,写起来简洁;多个宏组合用#if defined(),表达力更强。

另外还有一个新手容易踩的坑:#if后面如果直接跟宏名,比如#if DEBUG,此时 DEBUG 必须是有值的宏,否则会报错或者当作 0 处理。所以当你需要“按值判断”时,要确保宏有明确的数值定义,而不是空定义:

#define DEBUG_LEVEL 2 #if DEBUG_LEVEL >= 2 /* 细节日志 */ #endif

4.2 多分支判断:使用 #elif 而不是连写 #ifdef

条件分支多于两个时,#ifdef #else #endif就不够用了。好在预处理器还提供了#elif,等价于else if:

#ifdef PLATFORM_A #define DEVICE_NAME "Device A" #elif defined(PLATFORM_B) #define DEVICE_NAME "Device B" #elif defined(PLATFORM_C) #define DEVICE_NAME "Device C" #else #define DEVICE_NAME "Unknown" #endif

注意这里我写的是#elif defined(PLATFORM_B),这是#elif和defined()的标准配合。你也可以在#elif后面直接写常量表达式,但日常最常用的还是defined()判断宏是否存在。

多分支条件编译的可读性,很大程度上取决于分支顺序。我建议把最常见的情况放在最前面,这样后面读代码的人不必为了“什么条件的组合会走到这里”而翻遍整个文件。

4.3 # 和 ## 操作符:让宏更有“编程感”

既然提到了#define,就顺带说一下与它紧密配合的两个符号。#在宏定义中表示“把参数转成字符串字面量”,##表示“连接两个符号”。

#define STR(x) #x #define CAT(a, b) a##b STR(hello); // 展开为 "hello" CAT(foo, bar); // 展开为 foobar

这两个操作符在实际业务代码里不一定天天用,但在写日志库、注册表、自动化代码生成时非常有用。比如你想批量生成一组结构体变量的定义:

#define DEFINE_VAR(name) int var_##name = 0; DEFINE_VAR(a); // 展开为 int var_a = 0; DEFINE_VAR(b); // 展开为 int var_b = 0;

这种写法能大幅减少重复代码,但也要求你严格检查展开后的结果,因为编译器报错时看到的是展开后的代码,报错行号可能和源码行号对不上。

4.4 工程级约定:让条件编译从“能用”变成“不坑”

经过几年被宏坑的洗礼,我总结了几个工程规范,现在手下的项目都在执行。

宏命名要统一前缀。功能开关用FEATURE_开头,平台判断用PLATFORM_开头,构建模式用BUILD_开头。看到名字就能猜到用途,也能减少撞名风险。

宏定义尽量集中在少数几个配置头文件里,不要散落在各个.c文件中间。散落的宏就像散落的代码注释,时间长了没人知道谁定义过什么,很容易重复定义。

凡是跨文件使用的宏,必须在头文件里给注释说明用途。即使是团队内部项目,三个月后你也会忘记FEATURE_X到底是控制登录模块还是支付模块。

条件编译块不要太长。如果一个#ifdef里面包了五百行代码,说明该拆分了。把不同配置的代码拆成独立文件,用构建系统决定编译哪个文件,往往比用一堆宏嵌套更清晰。

5. 如何确认被裁掉的代码真的不存在:预处理器输出与哨兵宏

5.1 用预处理器输出验证

排查条件编译问题,最直接的方法是让编译器输出预处理后的代码。GCC 和 Clang 都支持-E参数:

gcc -E main.c -o main.i

生成的main.i文件就是预处理后的完整结果。你可以直接搜索某个函数名、某个变量定义还在不在。如果它不见了,说明某个#ifdef分支没走对,然后再逐层检查相关宏是否被定义。这个手段是我排查所有条件编译问题的第一招,比在源码里瞪眼看快得多。

如果你想快速确认某个宏当前有没有被定义,也可以在代码里加一条暂时的输出,但更优雅的方式是用编译器的内置行为:

gcc -DDEBUG -E -dM main.c | grep DEBUG

-dM会让编译器输出所有预定义宏的完整列表,再配合 grep 筛选,你就能拿到一份“当前宏定义全貌”。这对项目里宏特别多的情况尤其好用。

5.2 编译期哨兵:#error 与 #warning

除了事后查看结果,更好的办法是在问题发生前就让编译器主动告诉你走到了哪个分支。预处理指令里有两个哨兵很好用:#error在预处理阶段立即报错并终止编译,#warning输出警告但不中断。

举个例子,你想确认当前代码到底编译的是哪个平台:

#ifdef _WIN32 #warning "Compiling for Windows" #elif defined(__linux__) #warning "Compiling for Linux" #else #error "Unsupported platform!" #endif

编译时窗口里会直接打出对应的警告或错误信息,不用猜、不用查。这个方法在代码交给别人维护时特别管用:对方可以在不知道配置逻辑的情况下,通过警告信息快速定位当前编译环境。

#error还有一个用法:在配置阶段就拦截非法组合。比如某个功能模块必须在 DEBUG 模式下才能编译:

#if defined(FEATURE_EXPERIMENTAL) && !defined(DEBUG) #error "FEATURE_EXPERIMENTAL requires DEBUG mode!" #endif

与其让代码在后续编译中报出一堆莫名其妙的问题,不如在入口处直接拦住,给出清晰的错误说明。

5.3 条件编译问题的通用排查顺序

最后整理一个我排查条件编译问题的固定 SOP,你可以直接照做:

现象可能原因排查手段
编译报“未定义符号”函数或变量所在的条件分支被裁剪gcc -E查看预处理输出,确认代码是否还在
代码明明存在但不执行宏有值但被#ifdef以外的方式判断检查是#ifdef还是#if,确认值的真假
头文件内容生效不完整防重复包含的宏名冲突全局搜索相近的宏名,确认是否被其他头文件占用
预处理报错但不指向真实位置#if和#endif配对错误从上往下逐层数配对,重点检查嵌套处
构建系统反复编译同一段代码宏名拼写错误导致条件总为假用-dM导出宏列表,核对拼写

这套流程能覆盖 90% 以上的条件编译问题。剩下 10%,大多是宏定义和条件判断跨越多个文件嵌套导致的复杂交互,处理方式仍然不变:一层一层看预处理输出,直到定位到最原始的宏定义位置。

我自己现在写任何条件编译,都默认遵循几条铁律:#endif后面必带注释、条件编译块不超过三层、所有带#的代码写完立刻用-E看一眼展开结果。这些习惯平时看起来不起眼,但真的能帮你省下不少排查时间。希望这篇能让你下次再遇到#ifdef时,不仅知道它能干什么,还能知道它为什么这么干,以及出了问题该去哪里找答案。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 3:14:25

复杂SQL秒级提速:KingbaseES连接条件下推机制全解析

复杂 SQL 查询性能优化&#xff1a;深入解析 KingbaseES 的连接条件下推机制先讲一个我真实经历过的事。前几年接手了一个集团报表系统&#xff0c;其中一条 SQL 关联了 9 张表&#xff0c;跑一次要 40 多秒&#xff0c;业务方每天早上点开报表都要先泡杯咖啡等它出数。当时我根…

作者头像 李华
网站建设 2026/10/2 3:14:23

FFDNet PyTorch实战:可调噪声水平图像去噪全流程

简介&#xff1a;FFDNet-pytorch是一份面向图像去噪方向的深度学习实践资源&#xff0c;基于PyTorch复现了FFDNet&#xff08;Fast and Flexible Denoising Network&#xff09;模型&#xff0c;适合希望快速上手图像去噪的研究者、学生与工程开发者。资源包共404个文件&#x…

作者头像 李华
网站建设 2026/10/2 3:13:51

2D游戏动态光照管线:法线重构与实时光影的工业级实践

做2D项目&#xff0c;最怕听到的往往不是玩法改动&#xff0c;而是美术那边轻描淡写来一句&#xff1a;“这个场景加个火把&#xff0c;出个光照版吧。” 如果你做过暗黑类、横版动作&#xff0c;或者任何带昼夜循环的2D游戏&#xff0c;一定对“重绘地狱”深有体会&#xff1a…

作者头像 李华
网站建设 2026/10/2 3:13:51

Vue项目接入中控ID180身份证阅读器实战指南

最近在做一套Vue版的前后台管理系统&#xff0c;业务方提了个需求&#xff1a;在访客登记页面加一个身份证读取功能&#xff0c;要求直接用中控ID180二三代身份证阅读器扫一下&#xff0c;把姓名、身份证号、住址这些信息自动填进表单&#xff0c;不用手工录入。这个需求听起来…

作者头像 李华
网站建设 2026/10/2 3:13:33

R语言随机森林实战:生态数据建模、调参与可视化完整流程

简介&#xff1a;这份资源面向具备一定R语言基础、希望将随机森林方法应用于生态数据分析的学习者与科研人员&#xff0c;提供从数据准备到模型构建、评估与优化的完整实践素材。压缩包共2个文件&#xff0c;包含1个csv数据文件与1个R脚本&#xff0c;整体约4KB&#xff0c;体量…

作者头像 李华
网站建设 2026/10/2 3:13:30

HBase跨集群复制从原理到落地:WAL、Peer与容灾实战

搞大数据的同学&#xff0c;迟早会撞上这么个需求&#xff1a;业务要做多机房容灾了&#xff0c;线上集群的数据要汇到离线集群做分析了&#xff0c;老集群要整体换硬件了。你打开搜索引擎&#xff0c;跳出来的基本都是官方文档碎片&#xff0c;看着不难&#xff0c;真上手就会…

作者头像 李华