Python AI开发(python篇)学习笔记 -- 第五章 -- 抽象数据类型详解
Python AI开发(python篇)学习笔记 – 第五章 – 抽象数据类型详解
第五章:抽象数据类型详解
此文章是作者学习ai的完整笔记,在现在ai快速发展的时期,鼓励大家一起学习python!!!
路线图: Python >> 算法 >> Linux(Rocky、Ubuntu) >> 爬虫与数据分析 >> 机器学习 >> 高等数学 >> 大模型 …
目录
5.1 抽象数据类型基本概念理解(可变不可变类型)
1. 抽象数据类型(ADT)
抽象数据类型(ADT) 核心思想确实来自对现实世界的数学建模。
- 定义: 只定义了逻辑行为 和 操作规则,不涉及任何物理存储细节。(为保证安全ADT 的“只暴露操作,不暴露数据存储细节”)
python 代码世界 = ADT 理论工厂的流水线。
厂里图纸(ADT) 规定:产品要有“出生证”、“视力表”,但“整容手术”可选。
生产出来的 str: 只有出生证和视力表,拒绝整容 → 不可变类型。
生产出来的 list: 出生证、视力表、全套整容套餐都有 → 可变类型。
- 使用方法:
- 构造操作 如何创建抽象数据对象,例如:
int(5)、list()、[1,2,3] - 解析/获取操作 获取对象内部数据或属性,例如:
type(obj)、id(obj)、len(list)、dict.keys() - 修改操作(可选) 修改对象内部数据,例如:
list.append()、dict.update()
三步简称: 怎么产生、怎么看、能不能修改(可不可被操作、可不可变)
- 构造操作 如何创建抽象数据对象,例如:
- 例如:
str 对象类型不可被修改,所以为不可变类型,作者没有提供修改自身数据的方法入口,所以它在物理上不可变。list 对象类型可以被修改,提供了修改自身数据的方法入口(append、__setitem__),所以它在物理上可变。
举例: int类型
a = 10
print(id(a)) # 假设输出内存地址:1407123456789
a = a + 5 # 我们想让 a 变成 15
print(id(a)) # 输出内存地址:1407123456949 (变了!!!)
内存地址变了!
这说明 a = a + 5 这行代码并没有去修改原来那个写着 10 的内存格子。
真实发生的事情是:
- CPU 计算 10 + 5 得到新数字 15。
- Python 在内存里新申请了一块地皮,写上 15。
- 把标签 a 从 10 撕下来,贴到 15 上。
- 原来的 10 如果没人用了,就被垃圾回收。
1.有构造
int(5) 创建一个新对象2.有解析
a.bit_length() 查看二进制位数3.不可以修改本身
设计哲学是:
10永远都是10,你不能把10这个数学概念变成15。
str字符串:为什么 ADT 在数学上被建模为一个不可变的字符序列
- 为了安全和效率,字符串这个数学模型就不应该有修改操作。
举例: list类型
b = [1, 2, 3]
print(id(b)) # 假设输出:2200112233440
b.append(4) # 增加一个元素
print(id(b)) # 输出:2200112233440 (没变!!!)
内存地址没变
说明 append 操作直接冲进了原来那个内存格子,在里面加了一个格子放东西
真实发生的事情是:
1.有构造
list() 创建新容器2.有解析
b[0] 获取元素,len(b) 获取长度3.可以修改本身
b.append()、b[0] = 99、b.pop()
list这个 ADT 不仅提供了构造和解析,还完整提供了修改操作的接口。这允许它像一间可以随时装修的房子。
所以,int 和 str 的不可变性,不是 Python 做不出来,而是 ADT 的数学模型强制要求它们必须是值的代表
你把 ADT 的理论、实现、比喻都吃透了,现在问“主要使用场景”,这是从 “是什么” 走向 “什么时候用” 的关键一步。
5.2 使用场景(了解)
在真实开发中,ADT 不是让你挂在嘴边的术语,而是指导你设计代码结构的底层思维模型。以下五个场景,是 ADT 思维发挥决定性作用的地方。
场景一:选择数据结构时 —— “我到底该用 List 还是 Set?”
这是程序员每天都要做的决定。如果你不懂 ADT,你会纠结于性能(数组快还是链表快);但如果你懂 ADT,你会先问逻辑行为。
| 逻辑需求(ADT 视角) | 对应的数据结构选择 | 为什么? |
|---|---|---|
| 我需要一个先进先出的队伍 | collections.deque(队列 ADT) |
我只关心 push 和 pop 的顺序,不关心它是数组还是链表。 |
| 我需要一个不能重复的集合 | set(集合 ADT) |
我只关心“元素是否存在”,不关心底层哈希函数怎么算的。 |
| 我需要快速通过键找值 | dict(字典 ADT) |
我只关心 get(key) 和 set(key, val),不关心哈希冲突怎么解决。 |
ADT 的指导意义:
先看接口,再看实现。 接口(ADT)对了,程序逻辑就稳了;实现(数据结构)可以后期根据性能瓶颈替换,而不影响调用方。
场景二:定义 API 或库的公共接口时 —— “怎么让别人用我的代码最安全?”
假设你要写一个用户管理模块给公司其他同事用。
糟糕的设计(暴露实现细节):
class UserManager:
def __init__(self):
self.users = [] # 直接暴露一个列表
# 其他同事的代码
manager = UserManager()
manager.users.append("hacker") # 绕过你的业务检查,直接塞脏数据
manager.users = None # 直接把你的容器毁了
ADT 指导的设计(只暴露操作):
class UserManager:
def __init__(self):
self._users = [] # 隐藏
def add_user(self, name): # 构造/修改操作
if self._validate(name):
self._users.append(name)
def get_user_count(self): # 解析操作
return len(self._users)
场景价值:ADT 教会你把数据关在笼子里,只开几个合法窗口。这是防御性编程和库 API 设计的基石。
场景三:领域驱动设计(DDD)中的值对象 —— “钱和日期为什么不能改?”
在企业级软件开发中,有一类对象叫值对象,它们的特点是:相等性由属性值决定,且一旦创建就永不改变。
例子:货币金额
class Money: # 一个不可变的 ADT
def __init__(self, amount, currency):
self._amount = amount
self._currency = currency
def add(self, other):
if self._currency != other._currency:
raise ValueError("不能跨币种相加")
# 关键:返回新对象,而不是修改原对象
return Money(self._amount + other._amount, self._currency)
# 没有 set_amount() 方法
使用场景:电商算总价、银行转账记账。
为什么必须用不可变 ADT?
- 安全:如果一笔订单的金额
Money(100, "CNY")可以被中途篡改,财务系统就崩溃了。 - 并发:多线程同时读一个
Money对象,永远不需要加锁(因为没人能改它)。
场景四:算法实现时的状态隔离 —— “回溯、递归、多步撤销”
写算法题(比如 LeetCode)时,很多人会在递归回溯上栽跟头,原因就是没有利用 ADT 的“不可变”特性来管理状态。
例子:全排列问题
- 可变路径写法(容易出 Bug):
path = [] def backtrack(): path.append(x) # 改同一个列表 # ... 递归 ... path.pop() # 必须手动恢复现场,忘了就错 - 不可变路径写法(ADT 思维):
def backtrack(path_tuple): new_path = path_tuple + (x,) # 每次都创建新元组(不可变) # ... 递归 ... # 完全不需要 pop,因为原路径根本没变。
场景价值:在处理复杂状态流转(如 React 的 State、Redux 的 Store)时,不可变 ADT 让“时间旅行”(撤销/重做)变得极其简单——保留旧对象即可。
场景五:并发与多线程环境 —— “为什么 Python 的 GIL 没解决所有线程安全问题?”
Python 的 GIL 保证了单个字节码操作的原子性,但不保证复合操作的线程安全。
ADT 视角的解释:
- 可变类型(List):多个线程同时
append,可能发生数据竞争,需要加锁。 - 不可变类型(Tuple/String/Int):无论多少线程同时读、同时用,永远安全。
场景:
在多线程下载图片并记录 URL 时,如果你用一个共享的 list 来存 URL,你必须写复杂的锁逻辑。但如果你用不可变的 tuple 配合 functools.reduce,或者用 queue.Queue ADT(它内部封装了锁),你的代码会简洁且安全得多。
总结:ADT 使用场景速查表
| 当你想… | 你应该用 ADT 思维… |
|---|---|
| 选一个数据容器 | 先定义需要的逻辑行为(FIFO?去重?),再选满足该 ADT 的具体实现。 |
| 写一个给别人用的类 | 把数据藏起来(_),只暴露构造、解析、必要的修改三个操作。 |
| 定义一笔交易、一个坐标、一个日期 | 把它设计成不可变类型(不提供修改操作),保证业务安全。 |
| 写递归函数回溯搜索 | 尽量用不可变数据传递状态,避免手动恢复现场的麻烦。 |
| 写多线程代码 | 优先用不可变对象或线程安全的 ADT 实现(如 queue.Queue)。 |
一句话心法:
ADT 的使用场景 = 任何你需要“定义规则、隐藏乱麻、保护数据”的地方。
5.3 浅拷贝和深拷贝
学浅拷贝和深拷贝,是为了解决“可变对象共享”带来的副作用,从而保护 ADT 内部数据的纯净性。
第一层: 不拷贝
-
在 Python 中,
赋值操作 = 对于可变对象来说,只是贴了一张新标签,并没有复制内容。a = [1, 2, 3] b = a # 这就是“别名”,b 和 a 指向同一个列表 ADT 对象 b.append(4) print(a) # [1, 2, 3, 4] —— a 也被改了!
第二层: 浅拷贝 —— 解决了“外层”,没解决“里层”
- 为了解决上面的问题,我们给容器套一层皮。
# 为了解决上面的问题,我们给容器套一层皮。 import copy # 导入copy库(后续模块章节会详细讲解) a = [1, 2, [10, 20]] # 注意:里面还有一个可变列表 b = copy.copy(a) # 浅拷贝(或者 a.copy(), **a[:]** ) print(id(a)) # 不一样了 print(id(b)) # 外壳是新的 # 输出 133009188611008 # 输出 133009189212416 b.append(4) # 修改外层 print(a) # [1, 2, [10, 20]] —— 原列表外层没变,成功保护! # [1, 2, [10, 20]] # 但是! b[2].append(30) # 修改内层的那个小列表 print(a) # [1, 2, [10, 20, 30]] —— a 的内层被改了! # [1, 2, [10, 20, 30]]索引截取也属于浅拷贝,例如:
[:]、[1:5]等等、会额外生成新列表不修改原列表 - 浅拷贝只复制了最外面那个箱子。
- 箱子里的小盒子(可变对象 [10, 20])并没有被复制,新旧两个大箱子指向了同一个小盒子。
为什么要学这个?
- 因为在 ADT 设计中,如果你的数据是嵌套的可变结构(比如一个学生列表,每个学生是一个字典),浅拷贝并不能真正保护数据独立性。
第三层: 深拷贝 —— 完完全全的独立克隆
- 如果你想让外部拿到的数据完完全全是一个独立的副本,即使外部把副本炸了也影响不到原对象,那就必须用深拷贝。
import copy a = [1, 2, [10, 20]] b = copy.deepcopy(a) # 递归复制一切 b[2].append(30) print(a) # [1, 2, [10, 20]] —— 完全没变! print(b) # [1, 2, [10, 20, 30]]
深拷贝做了什么?
- 它递归地把每一层、每一个角落的对象都重新创建了一份。新旧数据之间没有任何共享的内存。
拷贝就发生在“解析/获取操作”的边界上。
总结
为什么要学浅拷贝和深拷贝?
为了在“共享效率”和“数据安全”之间做精确的权衡。
- 不拷贝:效率最高,但别名灾难。
- 浅拷贝:外壳独立,保护外层结构,但内层仍有牵连。
- 深拷贝:绝对安全,完全独立,但耗内存、耗时间。
作为一个遵循 ADT 原则的开发者,你的职责就是:在暴露数据给外部时,清楚地知道该给人家一份“复印件(浅)”、“克隆人(深)”,还是直接让人家进你家里翻(引用)”。
5.4 tuple元组序列
5.4.1 定义
由一系列数据对象组成的不可变序列,存储的是任意类型的对象;Python的元组类型为一种可迭代对象。
元组的创建语法
- 创建一个空元组:
tuple = () - 创建元组时初始化元组数据元素对象:
tuple = (n1,n2,n3,...)
5.4.2 元组对象构造操作
- 语法:
元组对象 = tuple(object) - 作用: 将传入的任意合法类型的对象构造为一个元组对象;
- 返回值: 元组对象;
- 使用方法:
t = tuple('12345') print(t) # 输出 ('1', '2', '3', '4', '5') t1 = tuple([1,2,3,4,5]) # 构造为tuple print(t1) # 输出 (1, 2, 3, 4, 5) l1 = list((1,2,3)) # 构造为list print(l1) # 输出 [1, 2, 3]
5.4.3 元组相关运算符
1. 元组拼接运算符
| 运算符 | 定义 |
|---|---|
+ |
元组拼接 |
* |
重复拼接对应次数的元组 |
2. 元组成员运算符
| 运算符 | 定义 |
|---|---|
in |
判断某数据对象是否存在于该元组,存在,返回True |
not in |
判断某数据对象是否不存在于该元组,不存在,返回True |
5.4.4 元组与列表区别
重点: 元组与列表操作相同,但元组创建了就不可改变,而列表可变
元组: 不可变,不支持 (append, pop, 索引赋值等等)
列表: 可变, 支持 (append, pop, 索引赋值等等)
若 元组里有可变列表,那个列表是可以变的。
(1, 2, [10, 20, 30])
5.4.5 元组推导式(生成器)
注意:与列表推导式不同,元组推导式无法直接创建元组对象;
其是先创建一个生成器对象,如需得到元组对象,再使用元组对象的 tuple() 构造即可
- 语法:
tuples = (关于 循环变量 的表达式 for 循环变量 in 可迭代对象 [if 过滤条件]) - 作用: 简化元组对象的构造过程;
- 使用方法:
t1 = (x ** 2 for x in range(1,5)) print(t1) # 输出 <generator object <genexpr> at 0x7cd9dc6bfd30> print(tuple(t1)) # 需要转化构造为tuple才可输出 (1, 4, 9, 16)- t1它打印出来的不是一个元组,而是一个 生成器对象。
- 如果你想要一个真正的元组对象,你需要把生成器表达式强制转换为元组
生成器用于大数据流处理、管道操作(后续详解)
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)