🐍 第8章 建立自定义类
📄 一句话概括

没有类的时候,数据用字典装、操作用函数写,两边分开。 谁都能往字典里塞一个错的键,函数也不知道自己该处理什么形状的数据。 类把两者捆在一起:数据是属性,操作是方法。方法跟着数据走,这是类和字典最大的区别。 __init__ 是「造一个出来的时候自动跑」的那个函数,self 就是刚造出来的那个对象本身。

📝 本章和前面几章的关系

前七章的代码都是「一堆函数 + 一堆变量」。这一章换个组织方式。 教材第 8 章开头两节是【自定义类:远离意大利面条】和【保存试算表数据的自定义类】。 「意大利面条」是讲一堆互相调用的函数绞在一起,看不出哪段归哪段。类是解这个的。 本篇是全书第一个 ⭐⭐⭐:能读懂的代码不算学会,能自己写出来才算。 演示里的类都不长, 建议照着敲一遍。

一、先看没有类的时候

用字典存一条货:{'品名': '六角螺栓 M8', '数量': 120, '单价': 1.5}。 算金额写个函数 total_of(rec),格式化再写一个。 能跑。演示第一段算出来 180.00,格式化也正常。问题不在跑不跑得起来,在两个月以后。 第一个问题:函数和数据是分开的。 谁都能往 rec 里塞一个错的键,写 rec['单介'] = 1.5 也不会报错,等你算钱的时候才发现是 0。

第二个问题:total_of 不知道自己该处理什么形状的字典。传一个缺「单价」的进去,它抛 KeyError; 传一个单价是字符串的进去,它算出一串拼接。 字段一多、函数一多,就乱了。 你会有一堆 total_of、fmt_of、check_of,名字都带 _of,因为它们都在操作同一种字典。 但语言层面看不出这层关系。 这就是「意大利面条」:面条本身没问题,问题是它们互相缠着,你找不到线头。

二、换成类

class Item: 开头,def __init__(self, name, qty, price): 造对象,def total(self): 算金额, def __str__(self): 决定 print 出来是什么样。 it = Item('六角螺栓 M8', 120, 1.5) 之后,it.total() 得 180.00, print(it) 得「六角螺栓 M8 × 120 = 180.00 元」。 __init__ 是「造一个出来的时候自动跑」的那个函数。 你不调它,Item(...) 这句就替你调了。 它的作用是把参数存进对象里。

self 就是刚造出来的那个对象本身。 写 self.name = name 意思是「把传进来的 name 存到这个对象的 name 上」。 self 不用你自己传。 写 it.total() 的时候,Python 自动把 it 塞进 self。 这就是为什么定义时要写 self、调用时不写。 一个高频错误:写 it.total 少了括号。 拿到的是方法本身,不是算出来的结果。 打印出来是 <bound method ...>,报错信息也不太好懂。

三、每个对象各有一份数据

同一个类造两个对象,a 是六角螺栓 120 个,b 是不锈钢垫片 500 个。 演示里改了 a.qty 之后,a 的金额变了,b 一点没受影响。 各存各的数据,共用同一套方法。 这是类最核心的一件事。 跟字典比,多出来的是「方法和数据的绑定关系」:a.total() 里的 total 知道自己在算 a 的金额, 不用你把 a 传进去。

这一条看着小,实际影响很大。 函数和数据绑在一起之后,「这段逻辑属于谁」就清楚了。 两个月后回来看代码,一眼知道该改哪个类。

四、类变量是所有人共用的

写在 __init__ 外面的变量是类变量,所有对象共用一份。 写在 __init__ 里面、带 self. 的是实例变量,每个对象各一份。 演示里 Counter 类有一个类变量 made,每造一个就加一。 造了三个之后 Counter.made 是 3,c1.made 也是 3——因为 c1 自己没有 made,它去类里找, 找到的就是那一份共用的。 判断标准很简单:这个东西是「每个对象各自的」还是「所有对象共享的」。

「一共造了多少个」「默认税率是多少」属于共享的;「这个货叫什么名字」「这个货有几个」属于各自的。 最经典的坑在这里:给 c1.made = 99 赋值, 这不是改类变量,是给 c1 造了一个新的实例变量。 于是 c1.made 是 99,Counter.made 还是 3, c2.made 也还是 3。 「改了一个,全都变了」和「改了一个,别的没变」,两件事的写法看着几乎一样,区别只在有没有赋值。 这一点必须自己试一遍才记牢。

五、继承:在已有的类上加东西

class DiscountItem(Item): 意思是「我做了一个新类,它跟 Item 一样,但有下面这些不同」。 演示里 DiscountItem 只重写了 total(打 8 折)和 __str__(显示折扣),其余全部照用父类的。 isinstance(d, Item) 为 True,哪怕 d 是 DiscountItem。 这是继承最实用的一点: 写了个只认 Item 的函数,打折商品也能传进去。

教材里那句「子类继承父类的所有方法,只把要改的那个重写掉」,就是这个意思。 重写(override)不是覆盖,是「我这个类用我自己的版本」。 super() 是在子类里调父类的写法。 想「在父类基础上再加一点」,就写 super().total() * 0.8,而不是把父类的公式抄一遍。 继承不要滥用。判断标准:两者是不是「是一种」的关系。 打折商品是一种商品,所以继承合理; 一张订单不是一种商品,那该用组合(把商品放进订单里)。

六、一个类管一张表

教材 8-2 节的做法:一个类装一整张表,再加几个「算总数」「按名字汇总」的方法。 演示第六段那张表四行、合计 654.00 元,还按品名汇总出了三行。这些都是 Table 类的方法。 以后要用这张表,import 这个类就行,不用每次重写一遍汇总逻辑。

这一段是上一篇那个「一百条记录」的自然延续:一堆记录放哪儿?放列表里,谁都能乱改; 放类里,读写都从这个类过,出问题只有一个地方要查。 类和函数的分界线不在语法,在「这段逻辑会不会被用第二次」。 只用一次的函数,包成类是负担; 用五次的,包成类省五次。

七、动手 Demo:类:把数据和操作它的方法捆在一起

先看没有类的时候怎么写的,再看换成类之后的样子。 然后验证两件事:两个对象的数据互不影响、类变量是共用的。 最后加一个打折子类,看 isinstance 怎么判。

print('① 先看没有类的时候')
rec = {'品名': '六角螺栓 M8', '数量': 120, '单价': 1.5}
total_of = lambda r: r['数量'] * r['单价']
fmt_of = lambda r: '%s × %d = %.2f 元' % (r['品名'], r['数量'], total_of(r))
print('  用字典', rec)
print('  算金额 %.2f' % total_of(rec))
print('  格式化', fmt_of(rec))
print('  但函数和数据是分开的:谁都能往 rec 里塞一个错的键,'
      'total_of 也不知道自己该处理什么形状的字典。')

print('② 换成类')
class Item:
    def __init__(self, name, qty, price):
        self.name, self.qty, self.price = name, qty, price
    def total(self):
        return self.qty * self.price
    def __str__(self):
        return '%s × %d = %.2f 元' % (self.name, self.qty, self.total())
it = Item('六角螺栓 M8', 120, 1.5)
print('  it = Item(\'六角螺栓 M8\', 120, 1.5)')
print('  it.total()  → %.2f' % it.total())
print('  print(it)   → %s' % it)
print('  type(it)    → %s' % type(it).__name__)
print('  注意 total 后面有括号才是调用:it.total 是 %r' % it.total)

print('③ 每个对象各有一份数据')
a = Item('六角螺栓 M8', 120, 1.5)
b = Item('不锈钢垫片', 500, 0.4)
print('  a → %s' % a)
print('  b → %s' % b)
a.qty = 200
print('  改了 a.qty 之后:')
print('  a → %s' % a)
print('  b → %s(没受影响)' % b)

print('④ 类变量是所有人共用的')
class Counter:
    made = 0
    def __init__(self):
        Counter.made += 1
c1, c2, c3 = Counter(), Counter(), Counter()
print('  造了三个,Counter.made = %d' % Counter.made)
print('  c1.made = %d(也是这个数)' % c1.made)
c1.made = 99
print('  但给 c1.made 赋值之后:c1.made = %d,Counter.made = %d,c2.made = %d'
      % (c1.made, Counter.made, c2.made))
print('  赋值不是改类变量,是给 c1 造了一个新的实例变量。')

print('⑤ 继承:在已有的类上加东西')
class DiscountItem(Item):
    def __init__(self, name, qty, price, rate=0.8):
        super().__init__(name, qty, price)
        self.rate = rate
    def total(self):
        return super().total() * self.rate
    def __str__(self):
        return '%s × %d = %.2f 元(打 %.0f 折)' % (self.name, self.qty, self.total(),
                                                     self.rate * 10)
d = DiscountItem('清仓螺栓', 100, 1.5)
print('  原价类 ', Item('六角螺栓 M8', 120, 1.5))
print('  打折类 ', d)
print('  类型   ', type(d).__name__)
print('  是不是 Item?', isinstance(d, Item))

print('⑥ 一个类管一张表')
class Table:
    def __init__(self, rows):
        self.rows = list(rows)
    def total(self):
        return sum(r.total() for r in self.rows)
    def by_name(self):
        out = {}
        for r in self.rows:
            out[r.name] = out.get(r.name, 0) + r.qty
        return out
t = Table([Item('六角螺栓 M8', 120, 1.5), Item('膨胀螺栓 10mm', 80, 2.3),
           Item('不锈钢垫片', 500, 0.4), Item('六角螺栓 M8', 60, 1.5)])
print('  表里 %d 行,合计 %.2f 元' % (len(t.rows), t.total()))
for k, v in t.by_name().items():
    print('    %-14s %d' % (k, v))
print('  一个类装一整张表,再加几个算总数、按名字汇总的方法。')
print('  以后要用这张表,import 这个类就行,不用每次重写一遍汇总逻辑。')

实跑输出:

--------------------------------------------------------------------
类:把数据和操作它的方法捆在一起
--------------------------------------------------------------------

  ① 先看没有类的时候
  --------------------------------------------------------------------
    用字典 {'品名': '六角螺栓 M8', '数量': 120, '单价': 1.5}
    算金额 180.00
    格式化 六角螺栓 M8 × 120 = 180.00 元
  能跑。
  但函数和数据是分开的:谁都能往 rec 里塞一个错的键,total_of 也不知道自己该处理什么形状的字典。
  字段一多、函数一多,就乱了。

  ② 换成类
  --------------------------------------------------------------------
    it = Item('六角螺栓 M8', 120, 1.5)
    it.total()  → 180.00
    print(it)   → 六角螺栓 M8 × 120 = 180.00 元
    type(it)    → Item
  __init__ 是「造一个出来的时候自动跑」的那个函数,self 就是刚造出来的那个对象本身。
  注意 total 后面有括号才是调用。
  写 it.total 拿到的是方法本身,不会算出金额——这个错很常见,报错信息还不太好懂。

  ③ 每个对象各有一份数据
  --------------------------------------------------------------------
    a → 六角螺栓 M8 × 120 = 180.00 元
    b → 不锈钢垫片 × 500 = 200.00 元
    改了 a.qty 之后:
    a → 六角螺栓 M8 × 200 = 300.00 元
    b → 不锈钢垫片 × 500 = 200.00 元(没受影响)
  同一个类造出来的两个对象,各存各的数据,共用同一套方法。
  这就是类和字典最大的区别:方法跟着数据走。

  ④ 类变量是所有人共用的
  --------------------------------------------------------------------
    造了三个,Counter.made = 3
    c1.made = 3(也是这个数)
  写在 __init__ 外面的是类变量,所有对象共用一份。写 self.made = ... 才是给这个对象单独造一份。
  想统计「一共造了多少个」用类变量,想给每个对象存自己的状态用实例变量。
  混着写就会出现「改了一个,全都变了」这种怪事。

  ⑤ 继承:在已有的类上加东西
  --------------------------------------------------------------------
    原价类  六角螺栓 M8 × 120 = 180.00 元
    打折类  清仓螺栓 × 100 = 120.00 元(打 8 折)
    类型    DiscountItem
    是不是 Item?True
  子类继承父类的所有方法,只把要改的那个重写掉。上面它重写了 total 和 __str__,其余照用。
  isinstance 判断「是不是某种东西」时,子类也算。
  这是继承最实用的一点:写了个只认 Item 的函数,打折商品也能传进去。

  ⑥ 一个类管一张表
  --------------------------------------------------------------------
    表里 4 行,合计 654.00 元
      不锈钢垫片          500
      六角螺栓 M8        180
      膨胀螺栓 10mm      80
  一个类装一整张表,再加几个「算总数」「按名字汇总」的方法。
  以后要用这张表,import 这个类就行,不用每次重写一遍汇总逻辑。
  这就是教材 8-2 节说的「保存试算表数据的自定义类」。
  类和函数的分界线不在语法,在「这段逻辑会不会被用第二次」。

第一段那两个 lambda 就是「没有类」的典型写法:函数操作字典,两边靠约定连在一起, 语言层面看不出关系。 第二段那个 it.total 是本篇最高频的错误。 少一对括号,拿到的是方法本身,打印出来是 bound method,不会算出金额。 第三段验证了「各存各的数据,共用同一套方法」。改了 a.qty,b 一点没受影响。 第四段那段赋值是重点:给 c1.made 赋值不是改类变量,是造了一个新的实例变量。 所以 c1.made 变 99,Counter.made 还是 3。这个坑必须自己跑一遍才记牢。 第五段 isinstance 返回 True,说明子类也算父类。 这就是「写了个只认 Item 的函数,打折商品也能传进去」的原理。 第六段那个 Table 类把「一堆记录」和「汇总逻辑」捆在一起, 这正是教材说的「保存试算表数据的自定义类」。

30 秒实验

  1. 把演示第二段的 it.total() 改成 it.total,打印出来看是什么,再试着加一,看报什么错。
  2. 在演示第四段末尾加一句 c2.made = 5,再看三个 made 各是多少。
  3. 给 Table 加一个 cheapest() 方法,返回单价最低的那一行,提示: min(self.rows, key=lambda r: r.price)。

常见误区

  1. 「it.total 和 it.total() 差不多。」差一对括号。 it.total 拿到的是方法本身,it.total() 才是算出来的数。 少写括号的报错信息不太好懂, 容易卡很久。
  2. 「给 c1.made = 99 就是改类变量。」不是,是给 c1 造了一个实例变量。 类变量还是原值,别的对象也还是原值。 要改类变量得写 Counter.made = 99。
  3. 「继承能复用代码,所以能继承就继承。」两者必须是「是一种」的关系。 订单不是一种商品,该用组合。 滥用继承会让类的关系越缠越乱。
自测三问(点开对答案) 1. `__init__` 和 `self` 分别是什么?为什么调用时不用传 `self`? —— `__init__` 是造对象时自动跑的那个函数,负责把参数存进对象。`self` 是刚造出来的那个对象本身。 调用 `it.total()` 时 Python 自动把 `it` 塞进 `self`,所以定义时要写、调用时不写。 2. 类变量和实例变量怎么区分?给对象的一个类变量赋值会发生什么? —— 写在 `__init__` 外面的是类变量(所有对象共用),写在里面带 `self.` 的是实例变量。 给对象赋值一个跟类变量同名的属性,**不是改类变量,是给这个对象造了一个新的实例变量**, 类变量和别的对象都不受影响。 3. `isinstance(打折商品, Item)` 为什么是 `True`?这有什么实际用处? —— 因为 `DiscountItem` 继承自 `Item`,子类的实例也算是父类的一种。 实际用处是:你写了一个只处理 `Item` 的函数,打折商品也能直接传进去,不用改函数。

下一篇回到存数据:同一份内容用 JSON、XML、pickle 各存一遍,看它们各自适合什么场合。

相关笔记