本书代码使用 python
重构
Chapter 2: 构造数据抽象
第一章中关注计算过程, 以及函数, 高阶函数
本章讨论复合数据: 对象
- 复合数据可以减少无用的细节纷扰
- 复合数据可以进一步提高程序的模块性
- 复合数据真正提高程序设计语言的表达能力
数据抽象能让我们在程序的不同部分建立起抽象屏障
关键思想:
- 闭包: 用于组合数据对象的粘合剂不但能用于组合基本的数据对象, 也可以作用于复杂的数据对象
- 复合数据对象能成为以 混合和匹配 的方式组合程序模块的 方便界面
2.1 抽象数据索引
基本思想: 构造出一些使用复合数据对象的程序, 使他们就像是在"抽象数据上操作一样
2.1.1 实例: 有理数的算术运算
如下是构造有理数类的一个实现方法
1 | def GCD(a, b): |
2.1.2 抽象屏障
python
类内的 calculate
等函数一般不被外接调用, 所以这类函数和外界由抽象屏障 \
抽象屏障 用于隔离不同层级之间的差异,通过屏障实现对上层程式与下层程式的沟通 \
这种方法可以将数据构建的依赖限制在小范围内
有利于对代码进行维护和修改后,整个系统的功能保持一致性。
例如之前的练习 2.1, 只需要改变
__init__
函数即可
2.1.3 数据意味着什么
数据是一组适当的选择函数和构造函数, 以及使这些函数成为一套合法表示, 它们必须满足的一组特定条件
对任何对象 $x$, $y$, 如果 $z$ 是 Point(x, y)
那么 x_point
就是 $x$, y_point
就是 $y$, 可以仅使用函数来实现点对
下面是点对的一种函数式表示
1 | def point(x, y): |
2.1.4 拓展练习: 区间算数
1 | class Interval: |
2.2 层次性数据和闭包性质
在 盒子和指针 表示方式中, 每个对象表示为一个指向盒子的 指针, 盒子里包含着对象的表示 \
闭包性质: 通过它组合起数据对象的结果本身还可以通过同样的操作再进行组合
2.2.1 序列(链表)的表示
使用 python
中的 list
1 | def cons(val, lst): |
使用链表
1 | class Pair: |
2.2.2 层次性结构
1 | class TreeNode: |
2.2.3 序列作为一种约定的界面
工作的过程可以抽象为一下几步: 枚举器, 过滤器, 转换器, 累计器
1 | def sum_odd_squares(cur): |
以上这两个函数都混杂了各个步骤, 不利于程序的抽象表达
1 | def add(a, b): |
抽象出 enumerate
, map
, filter
, accumulate
进行模块化设计
范围广大的许多操作都可以表述为序列操作
嵌套映射
1 | def make_pairs(n): |
1 | def permutations(lst): |
2.2.4 实例: 一个图形语言
语言中只有一种元素, 称为画家
勉强写了个分形的程序, 不能完成全部的 painter
功能
无法确认代码的正确性, 故跳过
1 | identity = lambda x: x |
2.3 符号数据
2.3.1 引号
scheme
语法, 略去
2.3.2 实例: 符号求导
简单的实现
1 | def is_variable(e): |
功能更全面的实现:
1 | class Expression: |
2.3.3 集合的表示
朴素不可重复集合:
union_set
, $\Theta(n^2)$intersection_set
, $\Theta(n^2)$is_element_of_set
, $\Theta(n)$adjoin_set
, $\Theta(n)$
1 | class Set: |
二叉搜索树:
1 | class Node: |
2.3.4 实例: 哈夫曼编码树
编码方式:
- 定长编码: 用相同位数的二进制码表示字符, 占用空间大
- 变长编码: 频繁出现的字符指定较短的二进制码
如何解决何时到达字符结尾:- 类似摩斯电码, 加间隔符
- 使用前缀码, 每个字符的完整编码都不是其他字符的前缀, 如
Haffman
编码
Haffman
树: 树叶代表被编码的符号, 其余节点表示一个集合, 包含其下树叶的所有符号
符号的权重只在构建哈夫曼树的时候才能用到, 在编码和解码的时候不需要使用
- 编码: 在哈夫曼树上, 向左走表示增加 0, 向右走表示增加 1
- 解码: 对于字符串, 在哈夫曼树上走, 0 表示左走, 1 表示右走, 走到叶子节点就是这段编码完成
回到根节点继续解码 - 生成哈夫曼树: 每次取权重最小的两个点合并
1 | class Leaf: |
2.4 抽象数据的多重表示
数据抽象是把使用该数据的程序设计和实现有理数的程序相分离, 构筑一道抽象屏障
但是同一种数据对象也可能有多种表示方法, 比如复数的直角坐标形式和极坐标形式
这就需要一个抽象屏障去隔离互不相同的设计选择
本章学习构造通用过程, 可以在不止一种数据表示上操作的过程
讨论数据导向的程序设计
2.4.1 复数的表示
1 | import math |
2.4.2 带标志数据
当两种表示方式放在一起的时候, 可以使用一个变量来表示当前变量的类型
这是一种剥去和加上标志的规范方式, 是重要的组织策略
1 | import math |
2.4.3 数据导向的程序设计和可加性
2.4.2 节实现的复数类有两个弱点:
- 如果想增加一种表示方式, 那就需要更改一系列选择函数
- 保证在整个系统里不出现名字相同的过程
综上, 这种形式不具有可加性
一种称为 数据导向 的程序设计的编程技术提供了这种能力
在处理一系列针对不同类型的通用操作的时候, 就像是在处理二维表格
第一维 就是所有的可能类型, 第二维就是所有的可能操作
通过这种表示: 只需要向表格里新加项目即可, 而不需要改变通用操作的代码
1 | import math |
在数据导向的程序设计里, 最关键的想法就是
显式处理 操作-类型表格 从而管理程序的通用性操作
在 2.4.2 里是一种基于类型进行分配的组织方式, 每个操作管理自己的类型
相当于把表格按照行分割
另一种实现策略是把表格按照列分割, 类似之前只使用函数来构建一个抽象数据的过程
先传入数据, 返回一个函数, 在传入操作类型执行操作
1 | def make_from_real_imag(x, y): |
2.5 带有通用型操作的系统
使用数据导向技术构造一个算数包, 包含有理数算数和复数算数以及常规算数
2.5.1 通用型算数运算
1 | import math |
2.5.2 不同类型数据的组合
目前定义的所有运算, 都把不同数据类型看成完全分离的东西
因此没有办法完成诸如 number
加上 complex
的操作
当然, 可以再定义复数加数字, 数字加复数两个函数, 从而达成目的
但是对于这样的系统, 引进一个新类型的代价就是不仅仅构造出针对这个类型的所有函数
还需要构造并安装好所有实现跨类型工作的函数, 这就是个非常复杂的任务
一般来说, 需要操作的两个类型都存在一种方式, 使得可以把一种类型看作另一种类型
比如数字加复数就是一个虚部为 0 的复数加复数
1 | def new_apply_generic(op, *args): |
对于 $n$ 个类型的系统, 转换函数可能需要写 $n^2$ 个
当然可以这两种类型转化成第三种类型来计算
类型的层次结构: 整数 -> 有理数 -> 实数 -> 复数
整数是有理数的子类型, 有理数是整数的超类型
类型塔: 一个类型只有至多一个超类型和至多一个子类型
可以采用逐步提高层级或者逐步下放层级的方式来实现类型统一
类型塔的另外一个优点, 每个类型能够 继承 其超类型中的所有操作
层次结构的不足:
一个超类型可能有多种子类型, 一个子类型可能有多个超类型,
所以并不存在唯一方式在层次结构中去提高一个层级或者下放一个层级
设计大型系统时, 处理一大批相互有关的类型而同时保持模块性是一个非常困难的问题, 目前仍在深入研究
2.5.3 实例: 符号代数
本小节实现一个多项式算数的系统(单变量多项式)
注意这里使用的是最初版本的 apply_generic
1 | from number import * |
加入了第二种表示:
1 | from number import * |
练习
2.1
更改 __init__
函数
1 | def __init__(self, a = 0, b = 1): |
2.2
1 | class Point: |
2.3
一个是用起始结束点表示, 一个是用对角线表示
1 | class Rectangle: |
2.4
1 | def cons(x, y): |
1 | cdr(t) |
2.5
1 | def cons(x, y): |
2.6
丘奇计数:
1 | zero = lambda f: lambda x: x |
2.7
见正文 2.1.4
2.8
$$
\begin{align}
& 定义两个区间, Interval(al, au), Interval(bl, bu), 其中 al < au, ; bl < bu \\
& p1, p2, p3, p4 = al - bl, al - bu, au - bl, au - bu \\
& p1 - p2 = bu - bl > 0, ; p1 > p2 \\
& p3 - p2 = au + bu - al - bl > 0, ; p3 > p2 \\
& p4 - p2 = au - al > 0, ; p4 > p2 \\
& p2 = min(p1, p2, p3, p4) \\
& p1 - p3 = al - au < 0, ; p1 < p3 \\
& p2 - p3 = -(p3 - p2) < 0, ; p2 < p3 \\
& p4 - p3 = bu - bl < 0, ; p4 < p3 \\
& p3 = max(p1, p2, p3, p4) \\
& 综上, 新的区间应是 Interval(p2, p3)
\end{align}
$$
2.9
$$
\begin{align}
& 定义两个区间, Interval(al, au), Interval(bl, bu), 其中 al < au, ; bl < bu \\
& 加法结果 Interval(al + bl, au + bu), 减法结果 Interval(al - bu, au - bl) \\
& 加法宽度: \frac{(au + bu - al + bl)} 2 = \frac{(au - al)}2 + \frac{(bu - bl)} 2 \\
& 减法宽度: \frac{(au - bl - al + bu)} 2 = \frac{(au - al)}2 + \frac{(bu - bl)} 2 \\
\end{align}
$$
1 | a = Interval(1, 2) |
2.10
见 2.1.4 __truediv__
函数
2.11
1 | def mul(x, y): |
经过时间测试, 新的分类乘法函数比之前的函数快了一丁点
2.12
见 2.1.4 PercentInterval
类
2.13
令 $a, b = PercentInterval(x_1, p_1), PercentInterval(x_2, p_2)$
两者相乘, $c = Interval(x_1 x_2(1-p_1)(1-p_2), (x_1 x_2(1+p_1)(1+p_2))$
又因为 $p_1p_2$ 非常小, 所以 $c$ 可以近似为 $Interval(x_1x_2(1-p_1-p_2), x_1x_2(1+p_1+p_2))$
所以可转换为 $c = PercentInterval(x_1x_2, p_1+p_2)$
2.14
浮点数误差导致误差甚至完全错误的结果
2.15
1 | a = PercentInterval(100, 0.5) |
看似 part2
计算的结果更加准确
Eva
的说法正确
2.16
这是区间的运算, 两种表达式并不等价
设计: …?
2.17
见正文
2.18
见正文
2.19
1 | def car(lst): |
不会, 因为组合方式是一定的
2.20
1 | def same_parity(*args): |
2.21
1 | def square_list_items_1(lst): |
2.22
第一个程序是每次把结果放在开头
第二个程序是把一个 list
cons
在一个 atom
上
2.23
1 | def for_each(lst, proc): |
2.24
1 | (1 (2 (3 4))) |
2.25
1 | x = [1, 3, [5, 7], 9] |
2.26
1 | (1 2 3 4 5 6) |
2.27
见正文
2.28
见正文
2.29
1 | class Mobile: |
只需要对选择函数做修改即可
2.30
1 | def square(self): |
1 | print(root.map(lambda x: x * x)) |
2.31
1 | def square_tree(cur_node): |
2.32
1 | def subsets(s): |
先拿出第一个元素, 找出后面所有元素形成的子集
然后把每个子集前面加上这第一个元素, 在算上原来的子集, 就是新子集
2.33
1 | def map_new(p, seq): |
def horner_eval(x, coefficient_sequence):
return accumulate(lambda this_coeff, higher_terms: this_coeff + higher_terms * x,
0, coefficient_sequence)
2.34
1 | def horner_eval(x, coefficient_sequence): |
2.35
1 | def count_leaves_new(cur_node): |
2.36
1 | def accumulate_n(op, init, seqs): |
2.37
1 | def mul(a, b): |
2.38
1 | def ford_left(op, initial, lst): |
2.39
1 | def reverse(seq): |
2.40
1 | def unique_pairs(n): |
2.41
1 | def unique_triple(n): |
2.42
1 | def queens(board_size): |
不抽象版本
1 | def new_queen(n, cur, state): |
2.43
原来是枚举 queen_cols(k - 1)
次 enumerate_interval
现在是枚举 enumerate_interval
次 queen_cols(k - 1)
看似没有区别, 实际上原来往下递归之后结果就保存住了
改代码之后往下递归被重复执行了 board_size
次
所以大概时间是 $T * board_size$ 次
2.44 ~ 2.52
目前以作者能力无法实现 painter
, 故无法运行代码
难以确认作业代码的正确性, 故跳过
2.53
1 | (a b c) |
2.54
1 | def equal(lst1, lst2): |
2.55
1 | (car ''abracadabra) |
2.56
见正文 “功能更全面的实现” 的 is_exponentiation
和 __pow__
函数
2.57
在正文 “功能更全面的实现” 中 second
函数多加两行代码即可
2.58
更改
1 | class MidExpression(Expression): |
至于不带括号的表达式, 需要构建表达式树
无法通过修改谓词和选择函数来解决
2.59
见正文 union
2.60
adjoin_set
, $\Theta(1)$union_set
, $\Theta(n)$
其他复杂度相同
但是这样做总体复杂度的系数比较大
在插入操作较多时建议使用这种, 否则使用上一种
1 | class MultiSet: |
2.61 ~ 2.62
1 | class SortedSet: |
2.63
产生同样结果
第一种方法使用 append
复杂度为 $\Theta(n)$, 总复杂度为 $\Theta(n^2)$
第二种方法使用 cons
复杂度为 $\Theta(1)$, 总复杂度为 $\Theta(n)$
第二种方法更优
2.64
往下递归, 左半部分是左子树, 右半部分是右子树
1 | 7 |
2.65
见正文
2.66
残缺版, 没有 key
函数
1 | def lookup(given_key, dir): |
2.67
1 | ADABBCA |
2.68
见正文
得到结果相同
2.69
见正文
2.70
1 | tree = generate_huffman_tree([["a", 2], ["na", 16], ["boom", 1], ["sha", 3], |
1 | [1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 0] |
总共的二进制位数量是 84
使用定长编码数量是 36 * 3 = 108
2.71
1 | * |
最频繁 1 个, 最不频繁 $n - 1$ 个
2.72
对于特殊情况
编码一个最频繁: $\Theta(1)$, 一个最不频繁: $\Theta(n)$
2.73
1 |
|
- 把之前的
is_sum
,is_product
等函数用get
的第二个参数代替, 从而实现选择的功能
因为number?
和is_same_variable
后面只有一个参数 - 见代码
- 加入乘幂
- 除了题中的改动, 只需要改动
install_deriv_package
下面的put
语句
2.74
题目不明确, 跳过
2.75
1 | def make_from_mag_ang(r, a): |
2.76
- 显式分派:
- 增加新操作需要使用者避免命名冲突
- 增加新类型需要改动通用操作
- 结论: 输
- 数据导向:
- 很方便地通过包机制增加新类型和新的通用操作
- 结论: 赢
- 消息传递:
- 将数据对象和操作整合在一起, 可以很方便地增加新类型
- 增加新操作时所有对象都要全部重新实例化
- 结论: 寄
2.77
代码见正文
因为不加那几行代码的话, 直接调用的是 complex
包里面的 magnitude
操作
此时 complex
包里面并没有这个操作, 所以加上就好了
magnitude
有三个, 第一个是最外层的, 第二个是 complex
包里面的, 第三个是 rectangle
里面的\
apply_generic
函数在前两次 magnitude
中被调用了两次
第一次剥去 complex
, 第二次剥去 rectangle
2.78
见正文
2.79
见正文
2.80
见正文
2.81
- 没有相应的类型转换操作
- 如果没有相应的操作, 那么就进行自己到自己的类型转换
从而陷入死循环, 寄 - 加一个
if
即可, 不写了
2.82
举例: 只有 complex
才实现了这个功能, 而传入的参数最高才是 rational
这样永远都找不到合适的过程
2.83
见正文中各个包的 raise
部分以及通用操作的 raise
2.84
见正文 install_depth_package
使用了一种比较简单的形式
2.85
见代码
2.86
过于复杂, 暂时搁置
2.87
见正文
2.88
见正文
2.89
见正文
2.90
见正文
2.91
见正文
2.92
见正文
2.93
见正文
2.94
见正文
2.95
因为长除法的时候, 无法除尽, 出现精度问题
2.96 ~ 2.97
暂时搁置