前几篇我们玩的"值"都是数字、字符串这种原子。可真实程序里,数据从来不是孤立的——一个学生有姓名、年龄、成绩;一个有理数有分子、分母。
当数据变复杂,"怎么存"和"怎么用"就会纠缠在一起,改一处崩一片。
CS61A 给出的解药叫数据抽象(data abstraction):用一层"接口"把两者隔开。本篇就用"有理数"这个小例子,把这个影响你一辈子的思想钉死。

一、数据抽象:构造函数 + 选择器

是什么:CS61A 对"数据"有一个极简也极深刻的定义——数据 = 构造函数(constructor) + 选择器(selector)

  • 构造函数:把零散的部件"打包"成一个整体(如 make_rat(n, d));
  • 选择器:从整体里"取出"某个部件(如 numer(r) 取分子、denom(r) 取分母)。

只要这两者行为一致,内部到底用元组、字典还是两个独立变量存,根本不重要。这层隔离叫抽象壁垒(abstraction barrier)

以"有理数"为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def make_rat(n, d):
return (n, d) # 构造函数:用元组存(实现细节)
def numer(r):
return r[0] # 选择器:取分子
def denom(r):
return r[1] # 选择器:取分母

def add_rat(a, b):
return make_rat(numer(a) * denom(b) + numer(b) * denom(a),
denom(a) * denom(b))

r = make_rat(1, 2)
print(numer(r), denom(r)) # 1 2
s = add_rat(r, make_rat(1, 4))
print(numer(s), denom(s)) # 6 8 (1/2 + 1/4 = 3/4,这里没约分)

输出:

1
2
1 2
6 8

是什么(再强调)add_rat 只通过选择器 numer/denom 和数据打交道,从不直接写 r[0]。这意味着——

坑(抽象壁垒被打破的代价):如果你在 add_rat 里直接写 a[0] + b[0],那就把"底层是元组"这个实现假设渗透到了上层。哪天你想把有理数改成"自动约分的结构体",上层全员报错。规矩是:只走构造函数/选择器,绝不戳内部表示。这层壁垒让你能单独换底层、上层纹丝不动。

本质一句话:数据抽象 = 用"构造函数+选择器"当接口,把"怎么存"和"怎么用"隔成两层,改底层不伤上层。

二、序列抽象:一类"可按下标访问"的对象

是什么:Python 里有一大类对象共享同一套接口——序列(sequence):能用整数下标访问、能求长度。这套统一接口就是 len(seq)seq[i]。只要实现了它,list、tuple、str 就能用同一套写法操作。

最常见的四种序列:

类型 可变性 字面量 典型用途
list 可变 [ ] 动态集合
tuple 不可变 ( ) 固定记录、作字典键
str 不可变 ' ' / " " 文本
range 不可变(惰性) range() 整数区间(篇七细讲)
1
2
3
4
5
lst = [10, 20, 30]
t = (1, 2, 3)
s = "abc"
print(lst[0], t[1], s[2]) # 10 2 c
print(len(lst), len(t), len(s)) # 3 3 3

str 不可变,所以 s[0] = 'x' 会直接报错。很多人误以为"字符串也能像列表一样改某个字符"——不行,要改只能整体重建:s = 'x' + s[1:]

本质一句话:序列抽象 = 一套 len/下标 统一接口,让不同容器能被同一套代码操作。

三、切片与列表推导:日常主力语法

切片(slice) seq[start:stop:step] 取出子序列,规则是左闭右开,且支持负数下标(从末尾数):

1
2
3
4
5
nums = [0, 1, 2, 3, 4, 5]
print(nums[1:4]) # [1, 2, 3] 下标 1 到 3
print(nums[::2]) # [0, 2, 4] 每隔一个取
print(nums[::-1]) # [5, 4, 3, 2, 1, 0] step=-1 即反转
print(nums[-2:]) # [4, 5] 倒数两个

列表推导(list comprehension) 是"生成列表"的紧凑写法,本质等价于 map + filter 的组合:

1
2
3
nums = [1, 2, 3, 4, 5]
squares = [x * x for x in nums if x % 2 == 1] # 只取奇数平方
print(squares) # [1, 9, 25]

输出:

1
[1, 9, 25]

:切片是浅拷贝新列表——对不可变元素(数字、字符串)没问题;但若列表里装的是可变对象(如子列表),新列表里的引用仍指向原来那些对象,改一个会影响"两边"。深浅拷贝的坑我们篇五接着挖。

本质一句话:切片 [start:stop:step] 取子序列、左闭右开;列表推导 [... for ... if ...] 是 map+filter 的语法糖。

四、为什么抽象是写大程序不崩的底线

把"数据怎么存"和"数据怎么用"分开,是程序能长大而不腐败的关键。一旦你引入了篇五要讲的可变性,抽象壁垒就更容易被打破——因为现在"存的东西"会被偷偷改掉。

所以理解清楚:

  • 不可变序列(tuple / str / range) 是抽象壁垒的"好朋友"——它保证拿到的数据不会被别人暗中篡改;
  • 可变序列(list / dict / set) 灵活,但用之前得想清楚"谁还能改它"。
1
2
3
4
5
6
7
8
理想的分层:
┌────────────────────────────┐
│ 上层逻辑:只认 构造函数/选择器 │ ← add_rat 这类,绝不直接戳内部
├────────────────────────────┤
│ 抽象壁垒(接口约定) │ ← 换底层实现的安全网
├────────────────────────────┤
│ 底层表示:tuple / dict / 类 │ ← 可以随便换
└────────────────────────────┘

小结

  • 数据抽象 = 构造函数 + 选择器,用接口隔离"存"与"用";只走接口、不戳内部,壁垒才立得住。
  • 序列统一接口:len() 与整数下标;list 可变、tuple/str/range 不可变。
  • 切片 [start:stop:step] 左闭右开、支持负数;列表推导是 map+filter 的语法糖(浅拷贝)。
  • 不可变序列是抽象壁垒的好朋友;可变序列灵活但需警惕"谁在改它"。