L11 类、实例与方法
L11从这一讲起你能定义自己的类型:把数据和操作打包进一个 class。
本讲速览
- 一切皆对象:对象 = 内部表示(data attributes)+ 接口(methods)
class就是自定义类型,__init__在创建实例时初始化数据属性self由 Python 自动传入,指向「点号前面那个对象」c.distance(zero)与Coordinate.distance(c, zero)是同一件事+ - * / == print len都是语法糖,背后是 dunder 方法,可以被重写- 重写
__mul__要return Fraction(top, bottom),返回 float 就把类型弄丢了
1. 对象与类型
- Python 支持很多种数据:
1234、3.14159、"Hello"、list、dict,每一个都是对象 - 每个对象都有两样东西:内部数据表示(primitive 或 composite)、一组与它交互的过程
- 对象是某个类型的实例:
1234是int的实例,"Hello"是str的实例
| 部分 | 别称 | 作用 |
|---|---|---|
| 数据属性 | data attributes | 内部表示,对外应当私有 |
| 方法 | procedural attributes | 接口,定义行为、隐藏实现 |
- 同一类型的对象,内部表示一样、接口也一样;弄懂一个
int,就弄懂了所有int - 换个说法:类型是模板,实例是按模板造出来的具体东西
- 对象可以创建、操作、销毁;
del x是显式销毁,也可以直接「忘掉」它 - 销毁或不可达的对象由 Python 回收,这叫 garbage collection
- 一切皆对象,所以「造一个新类型」就是造一种新对象,本讲用
class做这件事
真实世界的对象都是「状态 + 能做的事」:
| 对象 | 数据属性 | 操作 |
|---|---|---|
| 电梯 | 长宽高、最大载重、当前楼层 | 换楼层、加人、减人 |
| 员工 | 姓名、生日、工资 | 改姓名、改工资 |
| 商店队列 | 一串名字(list) | 尾部加入、头部取走 |
| 一摞煎饼 | 一串名字(list) | 尾部加入、尾部取走 |
队列是先进先出,煎饼堆是后进先出:同样的 list,接口不同,语义就不同。数据属性决定这类对象要记住什么,方法决定它能被怎么用,两者一起才算定义了一个类型。
拿 list 看「私有表示 + 公开接口」:
- 内部怎么存我们不用管、也不该管,这是类的私有表示
- 接口有
L[i]、L[i:j]、+、len()、min()、max()、del(L[i]) - 方法有
L.append()、L.extend()、L.count()、L.index()、L.insert()、L.pop()、L.remove()、L.reverse()、L.sort() - 绕过接口直接动内部表示,可能把对象弄成非法状态,所以内部表示要私有
- 私有不是道德要求,而是让「对象永远合法」这个承诺能站住:接口负责维护它,外部不该插手
2. 用 class 定义新类型
class就是类型;定义类要起类名,再定义类属性- 类属性分两种:数据属性(表示)、procedural 属性(接口,也就是方法)
- 用类是另一回事:创建实例,再在实例上做操作(
L = [1, 2]造 list,len(L)用它)
设计一个新类型前先回答两个问题,以 Coordinate 为例:
- 对象由哪些数据构成?二维平面上一个坐标就是
x和y - 对象能做什么?到 x / y 轴的距离、两个坐标之间的距离
- 这两个问题分别对应「数据属性」和「方法」,答完类的草图就有了
class Coordinate(object):
def __init__(self, xval, yval):
self.x = xval
self.y = yval
- 括号里的
object表示Coordinate本身也是对象、继承object的属性,可以省略 - 类体和
def一样靠缩进划分范围 __init__是特殊方法,创建实例时自动调用,用来初始化数据属性self.x = xval造出属于这个对象的变量;不写self就只是普通变量,实例上什么也没有- 类只写一次,所有实例共用这份模板;每个实例只负责存自己那份值
创建实例时不用给 self 传参,Python 自动补上:
c = Coordinate(3, 4)
origin = Coordinate(0, 0)
print(c.x) # 3
print(origin.x) # 0
- 实例的数据属性叫实例变量(instance variables),用点号访问,活到对象消亡
- 所有实例都有这些属性,但每个实例的值不同
内存里可以这样画:c 指向结构 {Type: Coordinate, x: 3, y: 4},origin 指向 {x: 0, y: 0}。c.x 就是顺着 c 找到结构、再取 x 的绑定。用变量造实例也不会让两个对象共享数据:
a = 0
orig = Coordinate(a, a)
print(orig.x, orig.y) # 0 0
a = 7
print(orig.x) # 仍然是 0
3. 方法与点号调用
- 方法就是「只对这个类有效的函数」,是 procedural attribute
- 两点距离对
Coordinate有意义,对两个list求距离就没意义,这是方法归属某个类的原因
class Coordinate(object):
def __init__(self, xval, yval):
self.x = xval
self.y = yval
def distance(self, other):
x_diff_sq = (self.x - other.x) ** 2
y_diff_sq = (self.y - other.y) ** 2
return (x_diff_sq + y_diff_sq) ** 0.5
c = Coordinate(3, 4)
zero = Coordinate(0, 0)
print(c.distance(zero)) # 5.0
print(Coordinate.distance(c, zero)) # 5.0,完全等价
- Python 调方法时总是把对象当作第一个参数传进去,约定叫
self - 除去
self和点号,方法和普通函数一样:收参数、做运算、返回结果 .能访问任何属性:数据属性c.x、方法c.distance(orig),my_list.append(4)也是同一机制append是list的方法,distance是Coordinate的方法,调起来长得一模一样
c.distance(orig) 的求值过程值得记一遍:
- 点号前面的对象是
c - 查出
c的类型是Coordinate - 点号后面的方法是
distance - 在
Coordinate这个类对象里找distance的绑定 - 用
c作self、orig作other调用它
注意第 5 步:你只写了 orig 一个参数,c 是 Python 从点号左边自动补进去的。
一句话:数据属性用 self.something 定义,方法就是类里第一个参数为 self 的函数。讲义把这个总结叫 big idea——点号只做两件事:取数据属性,或者调方法。
4. 用类搭类:Circle
写代码时要同时站两个视角:
| 视角 | 做的事 | 关注点 |
|---|---|---|
| 实现类 | 定义类、数据属性、方法 | 对象是什么、怎么用 |
| 使用类 | 创建实例、做操作 | 实例的具体值 |
- OOP 的核心:把相关的数据和处理它们的过程打包,通过良好定义的接口交互
- 好处是模块化开发:每个类单独实现、单独测试,复杂度被隔离开
- 每加一个新类,只需要想清楚它自己的数据与接口,其他类不用跟着改
Circle 不用自己存坐标,直接拿 Coordinate 当数据属性:
class Circle(object):
def __init__(self, center, radius):
self.center = center # 一个 Coordinate 对象
self.radius = radius # 一个 int
def is_inside(self, point):
return point.distance(self.center) < self.radius
center = Coordinate(2, 2)
my_circle = Circle(center, 2)
print(my_circle.is_inside(Coordinate(1, 1))) # True
print(my_circle.is_inside(Coordinate(5, 5))) # False
Circle只判断「点到圆心距离小于半径」,怎么算距离是Coordinate的事- 这就是抽象:调别人的接口,不关心它内部怎么实现
5. 从普通方法到特殊方法
先用最朴素的方式写分数类型,内部两个 int,接口是加和乘:
class SimpleFraction(object):
def __init__(self, n, d):
self.num = n
self.denom = d
def times(self, oth):
return self.num * oth.num / (self.denom * oth.denom)
def plus(self, oth):
top = self.num * oth.denom + self.denom * oth.num
bottom = self.denom * oth.denom
return top / bottom
f1 = SimpleFraction(3, 4)
f2 = SimpleFraction(1, 4)
print(f1.num) # 3
print(f1.denom) # 4
print(f1.plus(f2)) # 1.0
print(f1.times(f2)) # 0.1875
- 两个方法都返回 float:结果「像个数」,但已经不是
SimpleFraction了 - 练习:
get_inverse返回倒数的 float,invert原地交换分子分母、返回None
class SimpleFraction(object):
def __init__(self, n, d):
self.num = n
self.denom = d
def get_inverse(self):
return self.denom / self.num
def invert(self):
self.num, self.denom = self.denom, self.num
f1 = SimpleFraction(3, 4)
print(f1.get_inverse()) # 1.3333333333333333,有返回值
f1.invert() # 只改数据属性,不返回东西
print(f1.num, f1.denom) # 4 3
但 print(f1) 只会打出 <__main__.SimpleFraction object at 0x00000234A8C41DF0>,f1 * f2 直接报错。原因是 +、*、print 这些写法都只是语法糖,背后会被替换成方法:
| 写法 | 背后的方法 |
|---|---|
self + other |
__add__(self, other) |
self - other |
__sub__(self, other) |
self * other |
__mul__(self, other) |
self / other |
__truediv__(self, other) |
self == other |
__eq__(self, other) |
self < other |
__lt__(self, other) |
len(self) |
__len__(self) |
print(self) |
__str__(self) |
float(self) |
__float__(self) |
self ** other |
__pow__(self, other) |
- 名字前后各两个下划线,所以叫 dunder(double underscore)方法
- 在自己的类里定义同名方法,这些运算符就为自己的类型工作了
- 讲义还给了官方文档的位置:Python 数据模型里「Basic customization」那一节
__str__ 决定 print 打什么;不定义就只有一串内存地址:
class Coordinate(object):
def __init__(self, xval, yval):
self.x = xval
self.y = yval
def __str__(self):
return "<" + str(self.x) + "," + str(self.y) + ">"
print(Coordinate(3, 4)) # <3,4>
- 默认那串
<__main__.Coordinate object at 0x7fa918510488>里,地址是对象在内存中的位置,每次运行都变 __main__是模块名;换句话说,默认打印只告诉你「这是个什么类的对象」,不告诉你内容
给 Fraction 加 __str__、__mul__、__float__:
class Fraction(object):
def __init__(self, n, d):
self.num = n
self.denom = d
def __str__(self):
return str(self.num) + "/" + str(self.denom)
def __mul__(self, oth):
return Fraction(self.num * oth.num, self.denom * oth.denom)
def __float__(self):
return self.num / self.denom
a = Fraction(1, 4)
b = Fraction(3, 4)
c = a * b
print(c) # 3/16
print(float(c)) # 0.1875
print(Fraction(5, 1)) # 5/1,看着有点怪,下一节解决
方法版和 dunder 版的差别就在返回值:
| 项目 | times 方法 |
__mul__ 方法 |
|---|---|---|
| 调用方式 | f1.times(f2) |
f1 * f2 |
| 返回 | top / bottom,float |
Fraction(top, bottom) |
| 结果类型 | 丢了分数类型 | 仍是 Fraction |
三种写法完全等价,抽象就是这样工作的:
a = Fraction(1, 4)
b = Fraction(3, 4)
print(a * b) # 3/16
print(a.__mul__(b)) # 3/16
print(Fraction.__mul__(a, b)) # 3/16
6. 完善 Fraction:约分与类型检查
Fraction(1, 4) * Fraction(2, 3) 打出 2/12,没约分;Fraction(5, 1) 打出 5/1 也别扭。讲义留的练习是:分母为 1 时 __str__ 只打分子,所以 Fraction(3, 1) 应该打印 3。把这一讲的补丁合成最终版:
class Fraction(object):
def __init__(self, n, d):
self.num = n
self.denom = d
def __str__(self):
if self.denom == 1:
return str(self.num)
return str(self.num) + "/" + str(self.denom)
def __mul__(self, oth):
if isinstance(oth, Fraction):
top = self.num * oth.num
bottom = self.denom * oth.denom
elif isinstance(oth, int):
top = self.num * oth
bottom = self.denom
else:
raise TypeError("Fraction 只能乘 Fraction 或 int")
return Fraction(top, bottom)
def __float__(self):
return self.num / self.denom
def reduce(self):
def gcd(n, d):
while d != 0:
(n, d) = (d, n % d)
return n
if self.denom == 0:
return None
if self.denom == 1:
return self.num
g = gcd(self.num, self.denom)
return Fraction(int(self.num / g), int(self.denom / g))
a = Fraction(1, 4)
b = Fraction(2, 3)
print(a * b) # 2/12
print((a * b).reduce()) # 1/6
print(Fraction(4, 1) * 2) # 8,int 也能乘
print(float(Fraction(3, 4))) # 0.75
reduce里嵌了一个gcd,用辗转相除法:(n, d) = (d, n % d)一直转到d为 0- 拿
2/12走一遍:gcd(2, 12)→(12, 2)→(2, 0),返回 2,上下同除 2 得到1/6 - 分母为 0 返回
None;分母为 1 直接返回分子(一个 int),所以打印出来是8这种整数样 a * 2为什么需要分支?2没有num属性,所以__mul__要按oth的类型分开处理,其余类型raise TypeError- 类型检查不要写
type(oth) == Fraction,用isinstance(oth, Fraction),因为子类对象也该被当成父类(第 12 讲的 inheritance)
这一讲的 OOP 到此收口:
- 把相关数据和处理它们的 procedures 打包,通过良好定义的接口交互
- 常见运算背后全是 dunder 方法,
+、*、print、len都能为自己的类型定制 - 好处一:代码有条理、模块化,改一个类不牵连别人,好维护
- 好处二:数据和行为打包在一起,对象用起来方式一致,不会东一块西一块
- 好处三:能在已有对象上继续搭更复杂的对象,
Circle就是拿Coordinate搭出来的
常见错误
| 症状 | 原因 | 修法 |
|---|---|---|
TypeError: __init__() takes 3 positional arguments but 5 were given |
手动给 self 传了参数 |
创建实例时不写 self |
NameError: name 'x' is not defined |
x 只是 self 的属性 |
用 c.x 访问实例变量 |
TypeError: distance() takes 1 positional argument but 2 were given |
方法定义时漏了第一个参数 self |
写成 def distance(self, other) |
print(c) 打出 <__main__.Coordinate object at 0x...> |
没定义 __str__ |
定义 __str__ 返回想要的字符串 |
f1 * f2 报 TypeError |
没定义 __mul__,或返回了 float |
定义 __mul__ 并 return Fraction(...) |
AttributeError: 'Fraction' object has no attribute 'dsenom' |
属性名拼错(讲义原稿这里就有笔误) | 改回 self.denom |
讲义原文
- 本讲课件(54 页):Lecture 11 · 对象、类与特殊方法
Hyacine Studio