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 的内存格子。
真实发生的事情是:

  1. CPU 计算 10 + 5 得到新数字 15。
  2. Python 在内存里新申请了一块地皮,写上 15。
  3. 把标签 a 从 10 撕下来,贴到 15 上。
  4. 原来的 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) 我只关心 pushpop 的顺序,不关心它是数组还是链表。
我需要一个不能重复的集合 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它打印出来的不是一个元组,而是一个 生成器对象
    • 如果你想要一个真正的元组对象,你需要把生成器表达式强制转换为元组
生成器用于大数据流处理、管道操作(后续详解)
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐