Hyacine Studio
笔记 / L11

L11 类、实例与方法

L11
10334 字 约 26 分钟 提纲,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) 的求值过程值得记一遍:

  1. 点号前面的对象是 c
  2. 查出 c 的类型是 Coordinate
  3. 点号后面的方法是 distance
  4. 在 Coordinate 这个类对象里找 distance 的绑定
  5. 用 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

讲义原文