跳到主要内容

9. 类:面向对象其实不难

对应官方文档:9. Classes 一句话导读:这一章解决「数据和操作怎么捆在一起」——先想通「变量只是贴标签」,再顺出类、self、继承,最后用迭代器与生成器收尾,学完能看懂并写出别人写的类。

很多人卡在面向对象,不是卡在语法,是卡在「对象」太抽象。村长先给一句定心丸:Python 里的变量不是盒子,是标签。把这句吃透,self 和类变量会一起通。下面严格按 9.1 到 9.10 的编号走,9.2、9.3、9.5 底下再分小节,代码复制粘贴即可运行。

9.1 关于名称与对象​

一个对象可以同时被好几个名字指着,互称别名:对象是一间房子,b = a 只是又挂了一块门牌,并没造新房。id() 是对象的身份证号,is 判断是不是同一间房。房子分两种——可变对象(列表、字典)能在屋里改,就地修改会连累所有别名;不可变对象(数字、字符串、元组)不让改,所谓「改」其实是新建对象再贴标签。

a = [1, 2, 3]
b = a # 别名,指向同一个列表
b.append(4)
print(a, a is b, id(a) == id(b)) # [1, 2, 3, 4] True True
y = x = 1 # 整数不可变:新建对象 2 贴给 y
y = y + 1
print(x, y, id(x) == id(y)) # 1 2 False

村长划重点:b = a 后用 append、+= 改可变对象会影响所有别名,想分家要显式复制(b = a[:]);判身份用 is、比内容用 ==,别拿 is 比数值。

9.2 Python 作用域与命名空间​

命名空间是「名字 → 对象」的对照表(像一本通讯录),作用域是「一段代码能查哪本通讯录」的规则。查找永远由内往外:局部 → 闭包 → 全局 → 内置;而赋值默认在最内层新建名字,并不会去改外层同名变量。

9.2.1 作用域与命名空间示例​

官方的 scope_test 把 global / nonlocal 讲得最透,对照注释看三种声明各改了哪本通讯录:

spam = '模块最外层'
def scope_test():
def do_local(): spam = 'local spam' # 只在自己局部作用域新建
def do_nonlocal(): nonlocal spam; spam = 'nonlocal spam' # 改外层函数的 spam
def do_global(): global spam; spam = 'global spam' # 改模块顶层的 spam
spam = 'test spam'
do_local(); print('①', spam) # test spam
do_nonlocal(); print('②', spam) # nonlocal spam
do_global(); print('③', spam) # nonlocal spam
scope_test()
print('④', spam) # global spam

nonlocal 只能用在嵌套函数里、且要求外层已有该名字;global 直接指到模块顶层。两者只改变「赋值落点」,不负责创建变量。

9.3 初识类​

9.3.1 类定义语法​

class 名字: 加缩进块定义类。执行到 class 语句时类体会真的跑一遍,跑完生成类对象并绑定到类名;类名按约定用大驼峰,类体里的赋值和 def 分别成为类属性、方法。

class MyClass:
i = 12345
def f(self): return 'hello world' # 类体里的赋值与 def 变成类属性、方法
print(MyClass.i, MyClass.f(MyClass())) # 12345 hello world

9.3.2 类对象​

类对象只支持两种操作:属性引用(类名.名字)和实例化(类名())。实例化分两步:__new__ 造空实例、__init__(实例) 初始化,平时只写后者;类属性能改、也能现场新增。

class MyClass:
i = 12345
def __init__(self):
self.data = []
x = MyClass()
MyClass.extra = '随时加'
print(x.i, x.extra, x.data) # 12345 随时加 []

9.3.3 实例对象​

实例对象唯一能做的是属性引用:数据属性(实例变量)和方法;数据属性不用声明,第一次赋值就出现。

class Dog:
def __init__(self, name):
self.name = name
def greet(self):
return f'我是 {self.name}'
d = Dog('旺财')
print(d.name, d.greet()) # 旺财 我是 旺财
d.age = 3 # 事后也能挂新属性

self 就是「当前这个实例」,由 Python 调用方法时自动传入第一个参数:d.greet() 等价于 Dog.greet(d),你少写一个参数是因为 Python 把 d 塞进了 self,叫这名只是约定。而属性要写进 __init__,是因为它每造一个新实例就跑一次,才能保证每只狗有自己的 name;写在外面的类体里就不属于任何一只狗。

村长划重点:self 不是关键字,漏写它是新手第一大报错;实例属性务必写进 __init__,否则要么被所有实例共用、要么实例上根本没有这个属性。

9.3.4 方法对象​

实例.方法 会现场生成绑定方法对象:把函数与实例打包,调用时自动填好 self,所以不必再传实例。

class Counter:
def __init__(self):
self.n = 0
def inc(self):
self.n += 1
return self.n
c = Counter()
f = c.inc
print(f(), f(), Counter.inc(c)) # 1 2 3(后者等价,手动传 self)
print(c.inc.__self__ is c) # True:绑定方法记着实例

9.3.5 类变量与实例变量​

官方点名的坑:类变量被所有实例共享,实例变量每个实例各一份。读 self.tricks 时先查实例自己,没有才去类里找——于是 append 改的是类里那一个列表。

class Dog:
tricks = [] # 共享!
def __init__(self, name):
self.name = name
def add_trick(self, trick):
self.tricks.append(trick)
class GoodDog:
def __init__(self, name):
self.name = name
self.tricks = [] # 会变的数据放进 __init__
def add_trick(self, trick):
self.tricks.append(trick)
a, b = Dog('旺财'), Dog('小黑'); a.add_trick('坐下'); b.add_trick('握手')
print(a.tricks, a.tricks is b.tricks) # ['坐下', '握手'] True
x, y = GoodDog('旺财'), GoodDog('小黑'); x.add_trick('坐下')
print(x.tricks, y.tricks) # ['坐下'] []

村长划重点:可变的用实例变量,不变的常量才用类变量。self.x = ... 是赋值(在实例上遮住类变量),self.x.append(...) 是就地改共享对象——这一字之差就是 9.3.5 的灵魂。

9.4 一些补充说明​

官方攒的碎规则,挑最影响写代码的四条:数据属性会覆盖同名方法,命名别撞车;方法里引用同类属性必须写 self.,没有省略写法;方法可经 self 调用别的方法;类也能只放常量、当纯命名空间用。

class Config:
HOST, PORT = 'localhost', 8080 # 不实例化,纯命名空间
class Greeter:
def base(self):
return '你好'
def loud(self):
return self.base().upper() + '!'
print(Config.HOST, Greeter().loud()) # localhost 你好!
g = Greeter(); g.base = '被覆盖了'
try: g.loud() # 同名数据属性盖掉方法,调用时报错
except TypeError: print('果然炸了:字符串不可调用')

9.5 继承​

class 子类(基类): 表示「照抄父类能力,再改自己要改的」;子类没有的属性沿继承链往上找。重写时用 super() 拿父类版本,不把父类名写死。

class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f'{self.name} 发出声音'
class Dog(Animal):
def speak(self):
return f'{self.name}:汪汪'
def parent_speak(self):
return super().speak()
d = Dog('旺财')
print(d.speak(), '|', d.parent_speak()) # 旺财:汪汪 | 旺财 发出声音
print(isinstance(d, Animal), issubclass(Dog, Animal)) # True True

9.5.1 多继承与 MRO​

class D(B, C) 里 B、C 有同名方法听谁的?答案是 MRO(方法解析顺序):C3 线性化算出一条从左到右、每个类只出现一次的查找链,存在 __mro__ 里。super() 不是「调父类」,而是「按实例 MRO 找我后面那一位」。

class A:
def ping(self): return 'A'
class B(A):
def ping(self): return 'B->' + super().ping()
class C(A):
def ping(self): return 'C->' + super().ping()
class D(B, C):
def ping(self): return 'D->' + super().ping()
print(D().ping()) # D->B->C->A
print([c.__name__ for c in D.__mro__]) # ['D', 'B', 'C', 'A', 'object']

B 里的 super() 没去找 A,而是按 D 的 MRO 找到 C,这是理解多继承最关键的一句。多继承别乱用,组合功能优先考虑组合或 mixin;重写 __init__ 记得 super().__init__(...)。

9.6 私有变量​

Python 没有真正的私有:_x 靠约定「内部使用,别碰」;__x 会触发名称改写,被偷偷改名成 _类名__x,目的是防子类误覆盖,不是防盗。

class Secret:
def __init__(self, v):
self.__hidden = v # 实存 _Secret__hidden
def reveal(self):
return self.__hidden
s = Secret(42)
print(s.reveal(), s._Secret__hidden) # 42 42
try:
print(s.__hidden)
except AttributeError as exc:
print('直接访问失败:', type(exc).__name__)

9.7 零碎补遗​

官方补了三个「原来还能这样」:异常也是类(except ... as e 接到的就是实例);dataclass 自动生成 __init__ / __repr__ / __eq__,适合纯数据类;迭代器风格是给类定义 __iter__ / __next__(9.8 展开)。

from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float = 0.0
p = Point(1.5, 2.5)
print(p, p == Point(1.5, 2.5)) # Point(x=1.5, y=2.5) True
class MyError(Exception):
pass
try:
raise MyError('出事了')
except Exception as exc:
print(type(exc).__name__, isinstance(exc, MyError)) # MyError True

9.8 迭代器​

for 背后分两步:iter(obj) 拿迭代器,再反复 next(it) 直到抛 StopIteration。自定义类实现 __iter__(返回自己)和 __next__ 即可被遍历;迭代器是一次性的,走完就空。

class Reverse:
def __init__(self, data):
self.data, self.index = data, len(data)
def __iter__(self):
return self
def __next__(self):
if self.index == 0:
raise StopIteration
self.index -= 1
return self.data[self.index]
print(*Reverse('村长')) # 长 村(* 会自动迭代它)
it = iter([1, 2, 3])
print(next(it), next(it), next(it)) # 1 2 3

9.9 生成器​

函数里只要出现 yield,调用它就返回一个生成器:每 next() 一次执行到 yield 停住交值,下次从停住处继续,局部变量全留着。__iter__、__next__、StopIteration 全由 Python 自动生成,而且值一个一个算,特别省内存。

def reverse(data):
for i in range(len(data) - 1, -1, -1):
yield data[i]
for ch in reverse('golf'):
print(ch, end='')
print() # flog
g = reverse('ab'); print(next(g), next(g)) # b a

村长划重点:生成器只能往前、不能回头、不能 len(),遍历完就废;而且函数体要到第一次 next() 才真正执行,gen = reverse('abc') 这一行啥都没算。

9.10 生成器表达式​

列表推导的 [] 换成 () 就是生成器表达式:语法像推导式,行为像生成器,懒加载省内存;作为函数唯一实参时外层括号还能省。

print(sum(i * i for i in range(10))) # 285
print(max(len(w) for w in ['不好奇', '编程', 'Python'])) # 6
import sys
lst = [i * i for i in range(1000)]; gen = (i * i for i in range(1000)) # 前者立刻算完,后者只是「配方」
print(sys.getsizeof(lst), sys.getsizeof(gen))
print(sum(gen)) # 照样能用

要反复用、要索引、要 len() → 列表推导;只遍历一次、数据量大 → 生成器表达式。

本章小结​

  • 变量是标签不是盒子:b = a 是别名,is / id() 判身份;可变对象就地改会连累所有别名,不可变对象的「改」是新建。
  • 作用域由内往外查;赋值默认落最内层,改外层用 nonlocal、改模块顶层用 global。
  • self 是当前实例、由 Python 自动传入;实例属性必须写进 __init__;类变量被全体实例共享,可变数据放类变量必踩坑。
  • 继承用 class 子类(基类),super() 按 MRO 找「下一位」,顺序查 __mro__;__x 只是名称改写,不是真私有。
  • 实现 __iter__ / __next__ 得迭代器,函数写 yield 得生成器,圆括号推导式得生成器表达式——都懒加载、一次性、省内存。