news 2026/9/24 20:46:16

Python元组完全指南:不可变、解包与哈希的深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python元组完全指南:不可变、解包与哈希的深度解析

写Python这么些年,最常听见的一句话是:“元组不就是不能修改的列表吗?”对,但不全对。这句话会把你带到沟里去。元组的不可变特性,表面看是“不能改”,背后却牵连出哈希、解包、内存布局、安全设计等一系列连锁反应。这篇“09.Python 中元组完全指南”,就是想把这个看似基础的类型挖到底。搞懂它,你写代码的顺手程度、代码整洁度,都会明显上一个台阶。

不管你是刚装好Python、跟着教程学完列表就来看元组的入门选手,还是已经能拿爬虫、数据分析练手的进阶使用者,这篇文章都适合你。我会把元组的创建、操作、解包、命名元组、应用场景和常见坑整理清楚,中间穿插不少我实际写项目时踩过的坑和习惯用法,让你既能应付日常开发,也能在面试时把这块讲出层次。文章里的代码不多,但每段都值得亲手敲一遍,特别是那些“看起来没问题但跑出错”的例子。

1. 元组的本质:不可变序列与散列能力

1.1 从圆括号说起:逗号才是真正的主角

很多人记住元组的外形是圆括号,所以写代码时习惯性地加括号。但真正决定一个对象是不是元组的,是“逗号”。“逗号”才是元组的灵魂。看这段:

a = 1, 2, 3 # 这是一个元组 (1, 2, 3) b = (1, 2, 3) # 这也是元组 c = (1) # 这只是一个整数1,不是元组 d = (1,) # 这才是单元素元组

如果这个点没记住,后面写代码会踩很多莫名其妙的坑。最典型的是,当你想构造一个只有一个元素的元组时,漏掉逗号的后果极其隐蔽。比如你写coordinates = (42),你以为是坐标元组,实际拿到的是整数42,后续一旦遍历或者取下标,马上就报错,而报错信息往往让你摸不着头脑。我在代码评审时见过不少次这种低级但杀伤力巨大的错误,尤其是发生在函数默认参数、配置数据结构初始化里时,排查起来特别费劲。

我的经验是,不管需不需要括号,只要你想表达“这是一组有序、固定的数据”,就尽量把逗号写明确,并且在单元素的场合一定要补上那个“孤独的逗号”。另外,还有一个常见操作是tuple([1, 2, 3]),把列表转成元组,这种转换能帮你快速把动态数据“锁死”,后面就算别人再传什么,这个值也不会变。

关于“逗号决定元组”的规则,再补充一个引申知识:你写return 1, 2的时候,其实就在构造元组,Python 函数的“多返回值”本质就是这个。很多新手以为函数能返回多个独立值,其实返回的只是一个元组对象。理解这一点之后,你会开始理解解包等后续操作。

1.2 不可变性的三层实际含义

不可变性是元组区别于列表的关键。但不可变性不是一个空洞的词,它至少有三层实际意义:

  • 不能增删改:你没法向元组里appendinsert,也没法直接给某个下标赋值,这是最直观的层面。
  • 可以哈希:因为内容一旦创建便不会变化,所以元组可以被当作字典的键,也可以放进集合里做去重。列表做不到这一点。
  • 作为函数返回值更安全:函数内部拿到的元组,外部没法偷偷改它,这在协作开发、API设计里意义很大。

打个比方,列表像是白板上的字,随时可以擦掉重写;元组像是刻在石头上的字,写定就是写定,无法涂改,反而因此能作为“身份证明”使用。这个“身份证明”的能力,本质上就是从可哈希来的。

可哈希这个词,很多初学者听到就头大。简单讲,一个对象如果不改变,它就能做稳定的哈希运算,比如把整个元组换算成一串固定长度的数字。因为元组可变性为零,所以它的哈希值每次算都一致,字典拿到它能稳定建索。列表因为内容能变,哈希值也跟着变,拿去做键就乱套了。这就是为什么 Python 明确禁止把列表作为字典键。

但是注意,元组“可哈希”是有条件的:元组里面不能包含任何可变对象,否则这个元组整体会变得不可哈希。比如t = (1, [2, 3]),你把它放进字典键里,会直接报TypeError: unhashable type: 'list'。这个留到后面的坑里细说。

2. 创建与基础操作:从五秒钟上手到细节拿捏

2.1 创建元组的姿势盘点

创建列表大家熟,创建元组其实也有好几种姿势,我这里直接梳理一下:

  • 直接字面量:t = (1, 2, 3)t = 1, 2, 3
  • 单元素元组:t = (1,)
  • 使用tuple()构造函数:从列表、字符串、range等可迭代对象转换。例如tuple([1, 2, 3])tuple(range(5))
  • 使用生成器表达式:tuple(x**2 for x in range(3))。注意这里必须重复写tuple(),因为(x**2 for x in range(3))得到的是一个生成器对象,不是元组
  • 空元组:直接t = ()

我特别想强调一下tuple()的作用。很多人只在类型转换时想到它,但其实它还能做一次性消费迭代器的动作。比如你对一个生成器跑了半遍,后面想把它保存下来,或者需要拿到它的长度,这时候生成器本身是不支持索引和len()的,你可以用tuple()把它“凝固”成不可变数据。这个技巧在处理爬虫分页、数据分析的中间步骤时特别实用。

说到类型转换,热词里还有“python类型转换”。元组和列表互相转换非常常见,list(t)tuple(lst)来回切换,要注意的是,这种转换是“浅拷贝”,只是重新生成了容器,里面的元素还是同一个对象。如果元组里有列表,转成列表后,那个列表仍然共享。

2.2 基础操作硬知识与常用度排序

元组支持的运算和列表非常接近,但又有自己的边界。下面这张表整理了我认为最常用的操作、结果以及注意事项:

操作示例结果注意事项
索引t = (10, 20, 30); t[0]10支持负数索引,从末尾开始
切片t[1:3](20, 30)返回新的元组
拼接(1, 2) + (3, 4)(1, 2, 3, 4)生成新元组,不修改原对象
重复(1, 2) * 3(1, 2, 1, 2, 1, 2)和列表一致
成员检测2 in (1, 2, 3)True复杂度为 O(n)
长度len((1, 2, 3))3常用
统计(1, 2, 2).count(2)2元组自带
查找索引(1, 2, 2).index(2)1返回第一个匹配项

这些操作里,我实际用最多的是切片的负步长。比如反转一个元组t[::-1],这个操作简单高效,面试中也喜欢考。但很容易被忽略的是:元组切片返回的是新元组,而列表的reverse()是原地反转并返回None,两者语义完全不同,别搞混了。如果你要对列表做和元组切片同样的效果,建议用列表切片lst[::-1],它会生成新列表。

再提醒一个容易被忽视的点:元组虽然不能修改,但它的切片、拼接、重复操作效率通常都不错。因为不可变对象在内存中的布局更紧凑,Python 内部可以对元组做很多优化。你甚至可以把元组用作函数参数的一个“只读配置包”,避免调用方误修改。比如写一个定时任务配置:config = ('daily', 6, 30),后续不管怎么传,这个元组都不会被意外改掉。

3. 解包与命名元组:写出不像传统Python的优雅代码

3.1 解包的艺术:从基础到星号

解包是元组最亮眼的地方,也是很多老手写得最花的部分。最基础的,把元组里的值一次性拿出来:

point = (12, 5) x, y = point print(x, y) # 12 5

比基础更进一步的是星号表达式。Python 3 以后引入了*rest语法,可以优雅地处理“开头几个已知、中间一堆未知”的场景:

first, *middle, last = (1, 2, 3, 4, 5) # first=1, middle=[2, 3, 4], last=5

注意,middle得到的是列表,不是元组。这是一个高频误区和面试考点。原因倒不复杂:Python 设计者觉得,处理“不定长”部分用列表更方便后续操作,比如你要对middle遍历或修改。如果你希望它保持元组,可以自己再转一下:middle = tuple(middle)

解包最经典的用法是交换变量,不需要临时变量:

a, b = b, a

这是在等号右侧先构造(b, a)这个元组,然后立刻解包给a, b,实现在同一行里的交换。很多人天天用这招,却不知道背后是元组在默默打工。

解包还能配合函数参数一起用。例如你有一个元组args = (1, 2, 3),想把它作为三个位置参数传给函数f(a, b, c),可以写f(*args)。如果有一个字典想作为关键字参数传入,就写成f(**kwargs)。这种技巧在写通用工具函数、装饰器的时候特别高频,理解元组解包能帮你顺手理解*args的底层逻辑。

3.2 namedtuple:让数组成员有自己的名字

纯元组在字段一多时容易“失去灵魂”。比如(2024, "Python", 89),它到底表达什么?成绩单、订单、还是配置?旁人看代码,完全不知道2024是年份还是编号。这时候,namedtuple能救命。

from collections import namedtuple Student = namedtuple('Student', ['name', 'age', 'score']) stu = Student('Lila', 18, 92) stu.name # 'Lila' stu[0] # 'Lila',依然支持索引

namedtuple本质是一个元组子类,所以它不可变、可哈希,但同时拥有了字段名。用它写数据记录,比用字典更省内存,也比普通元组更清晰。在处理爬虫解析结果、数据库查询结果、配置文件项时,我经常用它定义“轻量级数据对象”。

尤其适合的场景是:一个数据从读取到展示,中间只要做几次字段筛选,用namedtuple能少写很多["字段名"]这种索引语法。它还自带了_replace方法,可以返回一个修改了某个字段的新实例,原对象保持不变。比如:

stu2 = stu._replace(score=95)

这在函数式风格里很好用。

再进一步,如果你的项目要求类型提示,可以考虑typing.NamedTuple

from typing import NamedTuple class Point(NamedTuple): x: float y: float

这在现代 Python 工程和 IDE 自动补全里更友好,也更符合团队协作的习惯。

3.3 解包的坑与变量命名的边界

解包虽然好用,坑也不少。最常见的是数量和长度不一致的报错:ValueError: too many values to unpack。当你对一个长度不确定的元组直接写死a, b = t,程序会崩溃。这时候最好用星号表达式吃掉多余部分,或者先处理长度判断。

还有一个容易被忽略的点:解包时变量名不要和后续逻辑冲突。曾经有个同学在项目里解包出一个id变量,导致后面id()内置函数被覆盖,排查了很久。这个问题不是元组带来的,但解包经常批量产生变量,一旦覆盖内置名称,影响面会很隐蔽。我的习惯是,语义明确的变量名(x,y,host,port)可以解包,通用型的ab尽量少用,尤其别覆盖 Python 的内置函数名。

4. 元组的舞台:函数返回、复合键与性能优化

4.1 函数多返回值:元组的“隐形出场”

函数里写return x, y时,其实就是返回元组。这是元组使用最频繁、也最容易被忽略的场景。很多入门的人不知道这个细节,以为 Python 能返回多个值,实际上它只是把那几个值打包成元组再返回。

因为返回的是元组,调用方可以灵活处理:

def get_user(): return 'Lila', 18 name, age = get_user() # 直接解包 info = get_user() # 或者整包接收 name, *_ = get_user() # 灵活忽略多余值

在写接口、写数据分析函数时,如果返回的字段超过两三个,我建议考虑用命名元组或数据类,否则调用方通过result[0]result[1]读数据,代码可读性会非常差。记住:函数签名就是接口文档的一部分,return host, port, timeout这种返回方式,虽然有解包加持,但字段一旦变多,往后的维护成本会成倍增长。

4.2 复合键的唯一解法:元组做字典键

这一块我觉得是元组价值最被低估的地方。在业务开发中,常常需要以“多个维度”作为某一个值的标识。比如统计每个城市每个月的销售额,你需要用(city, month)作为字典键。列表没法做键,因为不可哈希,但元组天然适合:

sales_map = {} sales_map[('北京', '2024-01')] = 10000 sales_map[('北京', '2024-02')] = 12000 sales_map[('上海', '2024-01')] = 9000

访问时直接sales_map[('北京', '2024-02')],逻辑非常清晰。如果你用两层嵌套字典sales_map['北京']['2024-02'],代码写起来更啰嗦,还得处理KeyError。我个人的经验是:只要复合键的维度不超过3个,用元组做键,代码干净程度会明显提升。

此外,因为元组可哈希,它还能直接放进set()做去重。比如爬虫里要记录“已经抓取过的 URL 和请求方式”组合,用(url, method)元组放进集合,天然去重,比字符串拼接再哈希要优雅不少,还不会拼出歧义。举个例子,"POST,/api/login""/api/login,POST"如果用字符串拼接,顺序搞反就可能出现重复或误判,但元组('POST', '/api/login')从根本上消除了这类歧义。

4.3 安全性:只读数据与 API 边界

元组的不可变性在程序设计上有一个隐藏优势:当你的函数接收一个“只读”配置时,用元组能在编译器层面阻止误修改。比如:

def connect(host, port, *, timeout=5): target = (host, port) # 后续别想改它 ...

这在多人协作时很有价值。传递一个元组意味着“这个数据到这里就不该再被修改”。团队里如果有人试图target[0] = 'evil.com',代码直接报错,问题能早早在开发期暴露,而不是上线后产生隐蔽的 bug。

不过也别过度设计。如果内部确实需要修改,用列表或数据类更合适。元组适合做“值对象”,不适合做“状态容器”。

4.4 性能:元组真的比列表快吗?快在哪

很多教程会告诉你“元组比列表快”,但没说清楚为什么。我实测过,纯索引访问、遍历的场景,元组确实略快。原因有两点:

  • 元组长度固定,内存能一次性分配完整,内部结构比列表简单,没有预留额外容量。
  • 因为不可变,Python 不需要为潜在的增长或修改维护额外开销。

不过,在做大量插入、删除、追加操作时,元组完败,因为每次操作都会生成一个新元组,时间和内存开销都很大。所以在并发场景、数据量大的流式计算里,千万别让元组承担动态数组的职责。

如果拿一份包含 100 万个整数的元组和列表做遍历求和,常规差距大概在 5%~10% 左右。差距存在,但多数业务代码里不必刻意追求。真正该用元组的理由,应该是语义清晰和可哈希,性能只是锦上添花。

另外,元组在内存占用上通常更省。sys.getsizeof一下就能看到:一个包含若干整数的元组,内存占用往往比同内容的列表小。这是因为列表会预分配额外容量,以便未来append,而元组不需要。这在一个进程里创建海量小记录时,差异会被放大,我在处理批量抓取结果时就有切身感受。

4.5 与数据分析、爬虫和“热词场景”的结合

结合搜索热词里频繁出现的爬虫、数据分析与可视化、量化交易等方向,元组在这些场景里的角色也值得说一说。在爬虫里,元组典型的用法是表示“坐标”或“结构记录”。比如用(x, y, width, height)表示目标图片在页面中的位置;解析 Excel 时,用(sheet_name, row, col)作为某个单元格的唯一标识;管理抓取队列时,用元组把(url, retry_count, timestamp)打包传递。

数据分析中,Pandas 的 MultiIndex 有时会涉及元组,把元组当作索引的某一层级,可以让数据筛选更清晰。绘图时,很多可视化库接受(x, y)元组作为坐标点,这个“点”正好匹配几何语义,比列表更合适。

至于量化交易策略代码,如果你写一个“回测引擎”,交易信号本身是固定结构的,比如(time, action, price, volume),用元组打包后放进历史序列,就能保证数据不会被后续逻辑意外篡改。整个过程里,元组基本都在做“固定结构的轻量记录”,这个定位贯穿所有场景。

5. 常见问题与避坑速查

5.1 单元素元组、空元组为什么这么容易写错

单元素元组(42,)与整数42的区别,是 Python 初学阶段的经典坑。很多变量定义、配置文件解析里,如果漏写逗号,表面不会报错,程序逻辑却会彻底跑偏。我建议在读取元组数据时,如果对数据集形状没有十足把握,可以写一个类型断言,或者直接用tuple(...)包一层,免得后续出现“对一个整数做遍历”这种诡异错误。

空元组反而没有歧义,()就是空元组。但有一个性能相关的冷知识:a = (); b = (),用a is b判断,结果是True。原因是 Python 把空元组做成了单例,所有空元组都指向同一个对象。这不是你需要刻意利用的特性,但了解之后,看内存分析报告时不会意外。

5.2 元组里的列表:不可变的“不可变”

前面提过,元组对内部的元素是“浅层不可变”。如果你写t = (1, 2, [3, 4]),那么t[0]不能改,但t[2].append(5)是合法的。这意味着,你把元组当作字典键时,如果元组内部包含列表,会直接抛TypeError。更隐蔽的是,如果元组里套着另一个元组,而那个内层元组又包含列表,整个结构依然不可哈希。

判断一个元组能否哈希,最简单的方法就是直接执行hash(t)。报错就说明里面有哈希不了的元素。在设计复合键时,务必确认每一层都不可变。如果确实需要可变数据参与组合,可以考虑把列表转成元组,或者使用frozenset这类安全类型。

5.3 元组拼接和重复的误用

t = (1, 2); t += (3, 4)这一行看起来很像“给元组追加元素”。实际上它做的是:创建新元组(1, 2, 3, 4),然后重新绑定给变量t。原来的(1, 2)还在内存里,等着被垃圾回收。如果你在一个循环里反复拼接,性能会非常难看。

另外,t *= 2的结果也容易看错。对包含列表的元组做乘法,比如(1, [2]) * 2,得到的元组里两个子列表其实是同一个对象的两个引用,修改其中一个,另一个也会变。这个坑在列表里也常见,但元组因为打着“不可变”的幌子,更容易让人放松警惕。这里本质是“引用复制”而非“深拷贝”,凡是嵌套容器,都建议手动验证一下共享关系。

5.4 可哈希边界:哪些对象会让元组失去哈希能力

不只是列表,setdictbytearray、自定义的可变类实例,都可能让元组失去哈希能力。如果遇到TypeError: unhashable type,不要过度惊讶。搞清楚“不可变”和“可哈希”的联系后,问题通常能一眼定位。

我还想加一个建议:不要在元组里塞太多种类不同的对象,除非你明确知道自己在做什么。比如(1, "a", [2], {"k": 3})这种混合结构,可哈希性已经没了,逻辑上也很难维护。元组适合做“结构紧凑的记录”,不适合做“万能口袋”。

5.5 IDE调试与学习环境相关

好些同学用 VSCode 或 PyCharm 调试元组时,发现变量监视面板显示得不直观,或者代码提示不出来。这多数不是元组的问题,而是解释器环境没配好。比如 VSCode 里没选择正确的 Python 解释器,Pylance 插件版本太老,就会导致类型推断和补全不正常。

建议先在终端里跑python -c "print(type((1,2)))"确认基础环境正常,再去看 IDE 配置。排坑要按“环境—语法—逻辑”的顺序来,别一上来就怀疑代码本身。如果你装了多个 Python 版本,最好在虚拟环境里操作,这样依赖和解释器版本都干净。

6. 我给新手的元组使用心得

6.1 判断标准:什么时候用元组,什么时候用列表

最直接的判断依据有两条:数据在语义上是不是固定的、需不需要作为字典键或集合元素。如果两个答案都是“是”,果断用元组。如果你的本意是“一组可增长、需要修改的数据”,就用列表。这个判断不需要很复杂。写代码时用对了容器,其实也是在表达设计意图。

我再补充一个倾向性建议:函数的返回值尽量用固定结构。如果返回值后续会被反复修改,用列表;如果只是“打包返回、解包消费”,用元组或命名元组。这样调用方的使用方式会稳定很多。

6.2 一个实用小案例:用元组管理坐标

我们可以做一个极简的“点”管理器,用来判断一个坐标点是否在矩形范围内。不涉及框架,只靠元组和基础逻辑:

def in_rect(point, rect): # point: (x, y),rect: ((x1, y1), (x2, y2)) x, y = point (x1, y1), (x2, y2) = rect return x1 <= x <= x2 and y1 <= y <= y2 p = (3, 4) rect = ((1, 1), (5, 6)) print(in_rect(p, rect)) # True

这段代码用解包把坐标点拆开,再用另一个解包把矩形对角线拆开,读起来像自然语言。如果用列表写,也能实现,但list在语义上就没有“点”和“矩形”那种固定结构的感觉。

6.3 把 namedtuple 引入你的项目试试

如果你还在用字典存一些固定结构的数据,下次可以试试namedtuple。比如爬虫里解析到一篇博客的标题、链接、摘要,用Post(title, url, summary)来存,比字典少了引号字符串,还多了属性访问。我在早期的爬虫脚本里就养成了这个习惯,后续做字段筛选、生成 Excel、构造 DataFrame 时都更顺手。当然,如果你需要频繁修改字段,建议用数据类dataclass或者干脆用字典,看场景灵活选择。

注意namedtuple也不能直接用默认参数做“可变的默认值”,它本身是元组子类,字段是不可变的。如果某个字段需要默认值,可以在定义时设置defaults参数,但该字段必须在无默认字段之后,避免歧义。

6.4 最后,关于学习路线的一个小建议

踩过几次“漏写逗号”“元组套列表”的坑之后,我才真正意识到:语法细节不是考点,是代码的底色。元组这个类型虽然基础,但它把“不可变”“可哈希”“解包”“命名”这几个关键概念串联在一起。搞懂它,你就能慢慢理解 Python 数据模型里更深的设计思想。

建议你花一个晚上,把坐标列表去重、用元组统计星座分布、给多返回值加上命名这几个小练习做一遍,把这些知识点揉进去,练完基本就掌握得很扎实了。后面再看列表推导式、生成器、*args、数据类时,你会发现它们之间有大量相通的设计逻辑。元组不是用来背的,是用来理解 Python 如何组合“数据与行为”的。

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

金融人工智能创新发展与安全治理框架构建实战指南

金融行业这两年最热的话题&#xff0c;十个里有八个绕不开人工智能。但真正在一线做过落地的人都知道&#xff0c;把模型跑通只是万里长征第一步&#xff0c;后面还有一堆硬骨头&#xff1a;数据合规怎么过、模型偏见怎么控、监管报送怎么对齐、出问题谁负责。我前后参与过几个…

作者头像 李华
网站建设 2026/9/24 20:45:48

基于SpringBoot+Vue的网上挂号就诊系统设计与实现

每年毕业设计选题的时候&#xff0c;总能看到一批“网上挂号就诊系统”出现在Java方向的备选清单里。说实话&#xff0c;这个题目的热度一直居高不下&#xff0c;核心原因就一条&#xff1a;业务场景足够真实&#xff0c;技术点足够全面&#xff0c;难度又刚好卡在一个能独立完…

作者头像 李华
网站建设 2026/9/24 20:44:15

从LeNet到现代CNN:工业视觉检测的深度学习落地实战指南

深度学习做视觉检测这件事&#xff0c;我在产线上摸爬滚打了几年&#xff0c;最大的感受是&#xff1a;它跟学术界做数据集刷榜完全是两码事。学术上你追求的是在CIFAR-10上把准确率从95%推到96%&#xff0c;工业现场你追求的是——这一批工件里有没有一个漏检的&#xff0c;以…

作者头像 李华
网站建设 2026/9/24 20:43:38

硬盘分区魔术师实战:Active启动分区设置与MBR/GPT转换全解析

1. 磁盘分区工具的核心价值与选型逻辑硬盘分区这件事&#xff0c;说大不大&#xff0c;说小也不小。但凡装过系统、调过启动项的人都知道&#xff0c;分区表一旦出问题&#xff0c;轻则系统进不去&#xff0c;重则整块盘的数据都得靠恢复软件去捞。而“硬盘分区魔术师”这类工具…

作者头像 李华
网站建设 2026/9/24 20:42:54

WWW与HTTP核心机制详解:从URL到状态码的实战排错指南

1. 从浏览器地址栏说起&#xff1a;WWW 到底是怎么把页面送到你眼前的每天打开浏览器&#xff0c;敲下一串网址&#xff0c;页面就出来了。这个过程太快、太自然&#xff0c;以至于绝大多数人从来没想过中间发生了什么。但如果你正在学网络、准备面试、或者被某个 502、连接超时…

作者头像 李华
网站建设 2026/9/24 20:41:58

Manus、OpenClaw、Hermes:智能体开发三阶段工具选型指南

1. 这三款智能体框架&#xff0c;根本不是“选哪个”的问题——而是你当前阶段该用哪一层工具最近在几个技术群和开发者论坛里&#xff0c;总看到有人问&#xff1a;“Manus、OpenClaw、Hermes&#xff0c;哪个最强&#xff1f;我该选哪个&#xff1f;”——这个问题本身&#…

作者头像 李华