第七章 面向对象程序设计
目录
第七章 面向对象程序设计
│
├── 1. 面向对象概述 → 面向过程 vs 面向对象 / 类与对象 / 三大特征
├── 2. 类的定义和使用 → class / __init__ / self / 实例属性与类属性
├── 3. 属性 property → 私有属性 / @property / setter / 只读属性
├── 4. 继承 → 单继承 / 方法重写 / super / 多继承与 MRO / 多态
├── 5. 实战 → 银行账户 / 购物车 / 图形计算器 / 学生管理重构
└── 6. 小结 → 知识清单 + 避坑清单 + 5 道题 + 下一章预告
照旧先建个第七章目录。这章的概念比语法重要,概念没搞懂,语法再熟写出来的类也是四不像。
1. 面向对象概述
1.1 先说清楚,什么是面向过程
你现在写的代码,基本都是面向过程的。想做一件事,就把它拆成一串步骤,一个函数一步,按顺序走完。
举个具体的,把一串学生数据读进来、算平均分、打印出来。
# 先造个数据文件,省得你手动建
with open("scores.txt", "w", encoding="utf-8") as f:
f.write("张三,88\\n李四,95\\n王五,72\\n赵六,61\\n孙七,90\\n")
def read_scores(path):
"""读文件,返回 [(姓名, 分数), …]"""
rows = []
with open(path, encoding="utf-8") as f:
for line in f:
name, score = line.strip().split(",")
rows.append((name, int(score)))
return rows
def average(rows):
"""算平均分"""
return sum(score for _, score in rows) / len(rows)
def top_n(rows, n=3):
"""取分数最高的 n 个"""
return sorted(rows, key=lambda row: row[1], reverse=True)[:n]
def report(rows, n=3):
"""汇总输出"""
print("平均分 %.1f" % average(rows))
for name, score in top_n(rows, n):
print(name, score)
rows = read_scores("scores.txt")
report(rows)
跑出来是这样。
平均分 81.2
李四 95
孙七 90
张三 88
这套写法顺得很,逻辑清楚,函数各管一摊。问题出在数据上。
rows 是一个装着元组的列表,数据的含义全靠下标和位置约定,row[0] 是姓名,row[1] 是分数。这个约定写在你脑子里,没写在代码里。哪天要加一列班级,row[1] 到底是分数还是班级,得把所有用到下标的地方全找一遍。
更要命的是,数据自己不会保护自己。分数本来该是 0 到 100 的整数,可谁都能塞个负数或者字符串进去,average 照算不误。
1.2 面向对象换了个思路
面向对象把着眼点从"步骤"挪到了"东西"上。学生是个东西,它有名字、有分数、有加分数的方法。别再拿一堆函数去搬这些数据,让数据自己带着能对它做的动作。
面向过程的思路 面向对象的思路
数据 ──┐ ┌──────────────┐
│ 交给函数 │ 学生对象 │
函数 ──┼──► 处理 ──► 结果 │ 姓名 = 张三 │
│ │ 分数 = 88 │
数据 ──┘ │ 加分() │
│ 评级() │
数据和操作是分开的 └──────────────┘
数据和操作在一起
两种写法没有谁对谁错,各有适合的场景,1.5 节我会把我的取舍标准写出来。
1.3 类、对象、实例,三个词一次说清
这三个词在书里反复出现,混着用的人不少。
| 类(Class) | 一类东西的模板、图纸 | 汽车的设计图纸 |
| 对象(Object) | 按照图纸做出来的实体 | 你家那辆具体的车 |
| 实例(Instance) | 就是对象,换个说法强调它是某类造出来的 | 这辆车是"汽车"类的一个实例 |
严格讲对象和实例是一回事,日常说话里"对象"用得多,讲继承关系的时候爱说"实例"。
类 Student(图纸,只有一份)
│
│ 用类名加括号,造出三个各自独立的对象
├──► stu1 姓名=张三 分数=88
├──► stu2 姓名=李四 分数=95
└──► stu3 姓名=王五 分数=72
三个对象结构一样,值各自独立。改 stu1 的分数,stu2 不受影响
1.4 三大特征,别背定义,看它解决了什么问题
书上都会写封装、继承、多态。我按"它到底解决了什么问题"来讲。
封装。把数据和对数据的操作包在一起,外面只留几个口子。好处是数据不会被随手改坏。银行账户的余额你不希望谁都能 account.balance = 1000000 吧,封装之后余额只能通过 deposit、withdraw 这些方法来动,方法里能加校验。
继承。有了一批相似的东西,把共同的部分抽到父类里,各自只写不一样的部分。猫和狗都会叫、都有名字,那就把这些放进 Animal,Dog 和 Cat 只写自己那声叫。省代码是一方面,更重要的是"猫是一种动物"这层关系被写进了代码结构里。
多态。同样一个动作,作用在不同对象上会有不同的表现,而调用方不用关心具体是哪个对象。你喊一声 speak(),猫喵喵、狗汪汪,写循环的那行代码根本不需要 if isinstance。
这三个词后面几节会一个个展开,现在留个印象就行。
1.5 什么时候不该用类
这段是我自己的看法,教科书上一般不写。
Python 不是 Java,它没有"所有代码都必须写在类里"的规矩。我在实际项目里的取舍是这几条。
| 一次性脚本,几十行搞定 | 纯函数,别搞类 |
| 数据有固定字段,且要反复传递 | 类 |
| 字段需要校验、计算或者只读 | 类加 property |
| 一组东西行为相似,只有细节不同 | 类加继承 |
| 只是想存几个值一起传 | namedtuple 或者字典就够 |
| 只有一两个方法,且没有共享状态 | 函数 |
见过最典型的过度设计是,为了存一个配置项写了个 Config 类,里面一个 __init__ 加一个 get,明明一个字典能解决的事。类是拿来管复杂度的,不是拿来显摆的。
我自己踩过的另一个坑是,把一堆本来互相独立的功能硬塞进一个大类,最后这个类两千行,改一处要读半天。类太大是坏味道,该拆就拆。
2. 类的定义和使用
2.1 写出第一个类
先把 1.1 那个学生数据改成类。
>>> class Student:
… def __init__(self, name, score):
… self.name = name
… self.score = score
…
>>> stu = Student("张三", 88)
>>> stu.name
'张三'
>>> stu.score
88
三行代码就完事了。对比一下用它和用元组的区别。
>>> stu = Student("张三", 88)
>>> print(stu.name, "考了", stu.score)
张三 考了 88
>>> row = ("张三", 88)
>>> print(row[0], "考了", row[1])
张三 考了 88
单看打印差不多,可 stu.name 和 row[0] 的可读性差了一个量级。半年后回来改代码,前者一眼看懂,后者得翻回去查约定。
2.2 class 的语法拆开看
class 类名: ← 大驼峰,首字母大写
│
├── def __init__(self, 参数): ← 构造方法,创建对象时自动调用
│ self.属性 = 值 ← 给这个对象绑上数据
│
├── def 方法名(self, 参数): ← 实例方法,第一个参数必须是 self
│ 方法体
│
└── 类属性 = 值 ← 直接写在类里、不带 self 的变量
三条命名规矩。
| 类名 | 大驼峰 | Student、BankAccount |
| 方法名 | 小写加下划线 | get_score、save_money |
| 私有属性 | 下划线开头 | _balance、__money |
Python 不强制,写错了能跑,但接手你代码的人会难受。
2.3 self 到底是什么,这节值得慢慢看
我带过几个刚学 Python 的人,卡住的第一处基本都是 self。它看着像凭空冒出来的规矩,其实就一句话,self 是这个对象自己。
做个实验就明白了。让方法打印一下它收到的 self 是谁。
>>> class Demo:
… def compare(self, other):
… print("self 和传进来的对象是同一个吗 ->", self is other)
…
>>> d = Demo()
>>> d.compare(d)
self 和传进来的对象是同一个吗 -> True
>>> other = Demo()
>>> d.compare(other)
self 和传进来的对象是同一个吗 -> False
d.compare(d) 里的 self 就是 d 自己。
第二个实验更能说明问题。self 是 Python 自动帮你传进去的,你写 d.hello(),Python 实际执行的是 Demo.hello(d)。
>>> class Demo:
… def hello(self):
… print("你好")
…
>>> d = Demo()
>>> d.hello()
你好
>>> Demo.hello(d)
你好
两种写法效果一样。第二种看着别扭,但它把"方法其实就是挂在类上的普通函数,只不过第一个参数收的是对象"这件事摊开了。
既然是自动传的,那定义的时候漏写 self 会出事。
>>> class Bad:
… def hello():
… print("你好")
…
>>> b = Bad()
>>> b.hello()
TypeError: Bad.hello() takes 0 positional arguments but 1 was given
报错说的是"这个函数要 0 个参数,却给了 1 个"。多出来的那个就是 Python 硬塞进去的 self。这个报错我见过不下二十次,几乎全是漏写 self 造成的。
还有个冷知识,self 不是关键字,改成别的名字照样跑。
>>> class Weird:
… def __init__(this, name):
… this.name = name
… def get(this):
… return this.name
…
>>> w = Weird("测试")
>>> w.get()
'测试'
能跑,但千万别这么写。全世界的 Python 代码都用 self,你写 this 只会让别人读的时候心里一咯噔。
d.hello() Python 背后做的事
│
▼
Demo.hello(d) ──► hello(self=d) ──► print("你好")
│
└─ self 就是调用它的那个对象
2.4 __init__ 不是创建对象,是初始化
很多人以为 __init__ 是在造对象。不完全对,造对象的是另一个方法 __new__,__init__ 只负责往造好的空壳里填数据。
>>> class Trace:
… def __new__(cls, *args):
… print("__new__ 造对象,收到", args)
… return super().__new__(cls)
… def __init__(self, value):
… print("__init__ 初始化,value =", value)
… self.value = value
…
>>> t = Trace(5)
__new__ 造对象,收到 (5,)
__init__ 初始化,value = 5
顺序一清二楚。平时你根本用不着写 __new__,知道有这么个环节就行,等哪天要写单例模式或者不可变类型的时候自然会再遇上。
完整的创建对象流程是这样。
stu = Student("张三", 88)
│
▼
① __new__ 造一个空对象出来
│
▼
② 空对象当 self 传进 __init__,绑上 name 和 score
│
▼
③ 这个对象返回出来,赋值到 stu
2.5 属性的增删改查
对象身上的属性存在一个小字典里,能加能删能查。
>>> stu = Student("张三", 88)
>>> stu.age = 18
>>> stu.__dict__
{'name': '张三', 'score': 88, 'age': 18}
>>> getattr(stu, "name")
'张三'
>>> getattr(stu, "gender", "未知")
'未知'
>>> hasattr(stu, "gender")
False
>>> del stu.age
>>> stu.__dict__
{'name': '张三', 'score': 88}
__dict__ 是对象存属性的那个字典,平时用不上,调试的时候往里面看一眼比什么都快。
getattr(对象, 名字, 默认值) 这个形式在处理用户输入的时候很顺手,字段名不确定也不会抛异常。
不过想加就加这个特性挺危险的。拼写错了也不报错,凭空多出一个属性,排查起来要命。想限制住,用 __slots__。
>>> class Point:
… __slots__ = ("x", "y")
… def __init__(self, x, y):
… self.x = x
… self.y = y
…
>>> p = Point(1, 2)
>>> p.x
1
>>> p.z = 3
AttributeError: 'Point' object has no attribute 'z'
__slots__ 一写,这个类就只能有 x 和 y 两个属性,多一个就报错。顺带的好处是省内存,属性多、对象多的场景下差距不小。
2.6 类属性和实例属性,两码事
这块的坑我踩过,而且踩得很疼。
写在类里、不带 self 的变量是类属性,全类共用一份。写在 __init__ 里、带 self. 的是实例属性,每个对象各有一份。
>>> class Dog:
… kind = "犬科" # 类属性,所有狗共用
… def __init__(self, name):
… self.name = name # 实例属性,每只狗一个
…
>>> d1 = Dog("小黑")
>>> d2 = Dog("小白")
>>> Dog.kind, d1.kind, d2.kind
('犬科', '犬科', '犬科')
改类属性,所有对象全都跟着变。
>>> Dog.kind = "哺乳动物"
>>> d1.kind, d2.kind
('哺乳动物', '哺乳动物')
反过来不行,实例属性不属于类。
>>> Dog.name
AttributeError: type object 'Dog' has no attribute 'name'
查找顺序要记牢,先找对象自己身上有没有,没有再去类里找,类里也没有就报 AttributeError。
d1.kind
│
├─ 查 d1.__dict__ → 没有 kind
│
├─ 查 Dog.__dict__ → 找到了 "犬科"
│
└─ 都没有 → AttributeError
接下来是那个把我坑疼的东西。如果类属性是个可变对象,所有对象共享的是同一个。
>>> class Dog:
… tricks = []
… def __init__(self, name):
… self.name = name
… def teach(self, trick):
… self.tricks.append(trick)
…
>>> a = Dog("小黑")
>>> b = Dog("小白")
>>> a.teach("坐下")
>>> a.tricks
['坐下']
>>> b.tricks
['坐下']
我只教了小黑坐下,小白也学会了。
Dog.tricks ──► [] ← 类里只有这一个列表
▲
│ 同一个对象
a.tricks ────┤
│
b.tricks ────┘
证据很好拿。
>>> a.tricks is b.tricks
True
>>> a.tricks is Dog.tricks
True
这个病因在第六章 2.4 讲默认参数时见过一次,都是"可变对象在定义时被创建了一次,之后大家共用"。修法也一样,把它挪到 __init__ 里,让每个对象自己造一个。
>>> class Dog:
… def __init__(self, name):
… self.name = name
… self.tricks = []
… def teach(self, trick):
… self.tricks.append(trick)
…
>>> a = Dog("小黑")
>>> b = Dog("小白")
>>> a.teach("坐下")
>>> a.teach("握手")
>>> a.tricks
['坐下', '握手']
>>> b.tricks
[]
这下各学各的了。
类属性也不是洪水猛兽,有个用法很正统,统计一共创建了多少个对象。
>>> class User:
… total = 0
… def __init__(self, name):
… self.name = name
… User.total += 1
…
>>> u1 = User("张三")
>>> u2 = User("李四")
>>> User.total
2
>>> u1.total
2
注意 User.total += 1 里写的是类名。写成 self.total += 1 的话,第一次赋值会在对象身上新建一个实例属性,类属性永远是 0,计数器就不灵了。这个区别值得自己敲一遍体会。
2.7 让对象打印出人能看的东西
直接打印一个对象,出来的是一堆地址。
>>> class Point:
… def __init__(self, x, y):
… self.x = x
… self.y = y
…
>>> p = Point(3, 4)
>>> print(p)
<__main__.Point object at 0x000001E4A5B6C7D8>
调试的时候这串东西毫无信息量。加个 __str__ 就好。
>>> class Point:
… def __init__(self, x, y):
… self.x = x
… self.y = y
… def __str__(self):
… return "(%d, %d)" % (self.x, self.y)
…
>>> p = Point(3, 4)
>>> print(p)
(3, 4)
>>> str(p)
'(3, 4)'
但直接敲 p 回车还是那串地址。
>>> p
<__main__.Point object at 0x000001E4A5B6C7D8>
因为交互环境回显用的是 __repr__,不是 __str__。两个都写上才算完整。
>>> class Point:
… def __init__(self, x, y):
… self.x = x
… self.y = y
… def __repr__(self):
… return "Point(%d, %d)" % (self.x, self.y)
… def __str__(self):
… return "(%d, %d)" % (self.x, self.y)
…
>>> p = Point(3, 4)
>>> print(p)
(3, 4)
>>> p
Point(3, 4)
两者的分工是这样,__str__ 面向用户,好看就行;__repr__ 面向开发者,最好是那种复制回代码里能重建对象的字符串。真不想写两个,就只写 __repr__,Python 会在没 __str__ 的时候拿它顶上。
2.8 方法其实也是属性
既然万物皆对象,方法当然也是。
>>> class Calc:
… def add(self, a, b):
… return a + b
…
>>> c = Calc()
>>> type(Calc.add)
<class 'function'>
>>> type(c.add)
<class 'method'>
>>> Calc.add(c, 1, 2)
3
>>> c.add(1, 2)
3
>>> f = c.add
>>> f(3, 4)
7
从类上取出来的是普通函数 function,从对象上取出来的是已经绑好 self 的 method。f = c.add 把方法存进变量,之后 f(3, 4) 不用再传 self,因为它已经粘在 c 身上了。
这个特性在回调、事件绑定里特别常见,第六章讲过的那一套在这儿一样成立。
2.9 私有属性,以及它其实不那么私有
属性加两个下划线开头,就成了私有属性。
>>> class Wallet:
… def __init__(self):
… self.__money = 0
… def save(self, amount):
… self.__money += amount
… def balance(self):
… return self.__money
…
>>> w = Wallet()
>>> w.save(100)
>>> w.balance()
100
>>> w.__money
AttributeError: 'Wallet' object has no attribute '__money'
外面访问不到。不过看一眼 __dict__,真相就露了。
>>> w.__dict__
{'_Wallet__money': 100}
>>> w._Wallet__money
100
Python 没有真正的私有,它只是把 __money 这个名字改成了 _Wallet__money,术语叫名字改写(name mangling)。你非要用全名去访问,照样拿得到。
所以这是约定而不是强制。Python 的哲学是"我们都是成年人",它给你一个明确的信号,但不会拿手铐铐住你。真要防的是误触,不是恶意。
三种下划线开头的写法,含义不一样。
| name | 公开 | 能 |
| _name | 约定上的内部用,别从外面碰 | 能,但不该 |
| __name | 名字改写,避免子类误覆盖 | 用改写后的全名能 |
| __name__ | Python 的特殊方法 | 别自己发明这种名字 |
最后一行那个要留意,__init__、__str__ 这种前后都有双下划线的是 Python 预留的,自己造一个 __size__ 出来,将来版本升级撞上了就麻烦。
2.10 静态方法和类方法
前面写的都是实例方法,第一个参数是 self。还有两种。
静态方法用 @staticmethod 装饰,不需要 self,逻辑上属于这个类但不碰任何实例数据。
>>> class MathTool:
… @staticmethod
… def add(a, b):
… return a + b
… @staticmethod
… def is_even(n):
… return n % 2 == 0
…
>>> MathTool.add(3, 5)
8
>>> MathTool.is_even(4)
True
>>> m = MathTool()
>>> m.add(1, 2)
3
注意 m.add(1, 2) 也能跑,静态方法不会收到 self,写成 m.add(1, 2, 3) 反而报错。
判断起来简单,一个方法从头到尾没用过 self,就该考虑改成静态方法。它多半只是恰好跟这个类有关联。
类方法用 @classmethod 装饰,第一个参数是类本身,习惯写成 cls。
>>> class Date:
… def __init__(self, year, month, day):
… self.year = year
… self.month = month
… self.day = day
… @classmethod
… def from_string(cls, text):
… y, m, d = text.split("-")
… return cls(int(y), int(m), int(d))
… def __str__(self):
… return "%04d-%02d-%02d" % (self.year, self.month, self.day)
…
>>> d = Date.from_string("2026-10-07")
>>> print(d)
2026-10-07
cls(…) 而不是写死 Date(…),好处是子类调用这个方法时造出来的是子类对象。这种模式叫工厂方法,解析各种格式的输入时特别顺手。
2.11 类的高频报错
| takes 0 positional arguments but 1 was given | 方法漏写了 self |
| missing 1 required positional argument: 'self' | 用类名直接调用了实例方法,应该先建对象 |
| AttributeError: type object 'X' has no attribute 'y' | 拿类名访问实例属性 |
| 改一个对象的属性,另一个也变了 | 类属性是可变对象,被共享了 |
| TypeError: __init__() takes … | 建对象时传的参数个数跟 __init__ 对不上 |
| 拼错属性名不报错 | Python 允许动态加属性,用 __slots__ 能挡住 |
第二条那个,我见过有人写 Student.get_name() 想省掉建对象的步骤,报的就是这个。
3. 属性(property)
这一节讲一个让 Python 的类比别的语言舒服不少的东西。
3.1 从一段翻车的代码说起
一开始我写的学生类是这样的,简单直接。
>>> class Student:
… def __init__(self, name, score):
… self.name = name
… self.score = score
…
>>> s = Student("张三", 88)
>>> s.score
88
运行得好好的,直到有一天教务处打电话过来,说有个学生的分数是 999。查下去发现是录入的时候手滑多敲了两个 9,而我的代码从头到尾没有一行拦得住。
>>> s.score = 999
>>> s.score = -20
>>> s.score
-20
属性是敞开的,谁都能改,改成什么都行。
第一反应是加校验。最土的办法是写一对 get 和 set 方法,把属性藏起来。
>>> class Student:
… def __init__(self, name, score):
… self.name = name
… self.set_score(score)
… def get_score(self):
… return self._score
… def set_score(self, value):
… if not 0 <= value <= 100:
… raise ValueError("分数必须在 0 到 100 之间")
… self._score = value
…
>>> s = Student("张三", 88)
>>> s.get_score()
88
>>> s.set_score(120)
ValueError: 分数必须在 0 到 100 之间
能拦住了,代价是调用全变了。项目里凡是写过 s.score = x 的地方,全得改成 s.set_score(x),凡是读过 s.score 的地方,全得改成 s.get_score()。从 Java 转过来的人对这套熟得很,可 Python 里这么写实在啰嗦。
3.2 property 就是来解决这个的
用 @property 之后,外面还是 s.score 这种写法,底下却已经换成了方法。
>>> class Student:
… def __init__(self, name, score):
… self.name = name
… self.score = score
… @property
… def score(self):
… return self._score
… @score.setter
… def score(self, value):
… if not 0 <= value <= 100:
… raise ValueError("分数必须在 0 到 100 之间")
… self._score = value
…
>>> s = Student("张三", 88)
>>> s.score
88
>>> s.score = 95
>>> s.score
95
>>> s.score = 120
ValueError: 分数必须在 0 到 100 之间
>>> Student("李四", 999)
ValueError: 分数必须在 0 到 100 之间
看清楚发生了什么,s.score = 95 看着像普通赋值,实际调用的是那个带 @score.setter 的方法。而 s.score 看着像读属性,实际走的是 @property 那个。
调用方的代码一行不用改,校验就悄悄加上了。这是 property 最大的价值,在不破坏已有接口的前提下,把裸属性升级成受控属性。
注意 __init__ 里写的是 self.score = score 而不是 self._score = score,故意让它走一遍 setter,这样连创建对象时传进来的脏数据也拦得住,最后那个 Student("李四", 999) 报错就是证据。
3.3 语法细节,三个装饰器是一套的
@property ← 装饰读的方法,方法名就是属性名
def score(self): …
@score.setter ← 装饰写的方法,必须叫同一个名字
def score(self, value): …
@score.deleter ← 装饰删的方法,可选
def score(self): …
三条规矩。
方法名就是对外暴露的属性名,三个装饰器下面的函数名必须一致,写错了就变成两个不相干的东西。
顺序无所谓,但习惯上 getter 写最前面。
只有 getter 没有 setter,这个属性就是只读的。
deleter 用得少,看一眼知道它存在就行。
>>> class Demo:
… def __init__(self):
… self._x = 1
… @property
… def x(self):
… return self._x
… @x.setter
… def x(self, value):
… self._x = value
… @x.deleter
… def x(self):
… print("x 被删了")
… del self._x
…
>>> d = Demo()
>>> d.x = 5
>>> d.x
5
>>> del d.x
x 被删了
>>> d.__dict__
{}
3.4 只读属性,不给 setter 就行
有些值天生不该被外面改,比如圆的面积,它是半径算出来的,改它没有意义。
>>> class Circle:
… def __init__(self, radius):
… self.radius = radius
… @property
… def area(self):
… return 3.14159 * self.radius ** 2
… @property
… def perimeter(self):
… return 2 * 3.14159 * self.radius
…
>>> c = Circle(2)
>>> c.area
12.56636
>>> c.perimeter
12.56636
>>> c.radius = 3
>>> c.area
28.27431
>>> c.area = 100
AttributeError: property 'area' of 'Circle' object has no setter
有意思的是周长和面积在半径为 2 的时候数值一样(12.56636),这不是笔误,2πr 和 πr² 在 r=2 时确实相等。
改半径,面积跟着变,因为它是每次读的时候现算的。这种"看起来是属性,其实是计算"的写法我最爱用,调用方根本不用管它背后是字段还是算式。
3.5 一个更日常的例子,算折扣
>>> class Goods:
… def __init__(self, name, price, discount=1.0):
… self.name = name
… self.price = price
… self.discount = discount
… @property
… def final_price(self):
… """折后价,打几折乘几"""
… return round(self.price * self.discount, 2)
… @property
… def saved(self):
… """省了多少"""
… return round(self.price – self.final_price, 2)
… def __str__(self):
… return "%s 原价%.2f 折后%.2f 省%.2f" % (
… self.name, self.price, self.final_price, self.saved)
…
>>> g = Goods("机械键盘", 299, 0.8)
>>> g.final_price
239.2
>>> g.saved
59.8
>>> print(g)
机械键盘 原价299.00 折后239.20 省59.80
>>> g.discount = 0.5
>>> print(g)
机械键盘 原价299.00 折后149.50 省149.50
折扣一改,折后价和省的钱自动跟着变。要是把 final_price 写成普通属性,改完折扣还得记得手动重算一次,改的地方一多迟早漏。
3.6 老代码里的另一种写法,认得就行
@property 是装饰器语法,底层其实是个内置函数 property()。早年的代码常这么写。
>>> class Celsius:
… def __init__(self, temperature=0):
… self.temperature = temperature
… def get_temperature(self):
… return self._temperature
… def set_temperature(self, value):
… if value < -273.15:
… raise ValueError("温度不能低于绝对零度")
… self._temperature = value
… temperature = property(get_temperature, set_temperature)
…
>>> c = Celsius(37)
>>> c.temperature
37
>>> c.temperature = -300
ValueError: 温度不能低于绝对零度
property(读, 写, 删, 文档) 四个参数,跟三个装饰器一一对应。现在写新代码用装饰器就好,看到这种老写法能认出来就行。
3.7 什么时候该用 property
我用 property 的标准就四条,命中一条就值得上。
| 值需要校验 | 分数 0 到 100、温度不低于绝对零度 |
| 值是算出来的 | 面积、折后价、年龄(从生日算) |
| 想让它只读 | 订单号、创建时间 |
| 老接口要加校验又不想改调用方 | 裸属性升级 |
反过来的情况也一样清楚,如果一个值就是单纯存着,没有任何限制,那直接写 self.name = name 就好,外面 obj.name 直接读写,别为了"规范"把每个属性都包一层 property。我见过有人给十几二十个字段全写了 getter 和 setter,里面就一句 return self._x,纯属给自己找事。
Python 的好处在于,你今天写成裸属性,哪天真需要校验了,加上 @property 就完事,调用方毫发无伤。别的语言没这个后悔药,所以才要一开始就全写成 get/set。
3.8 写 property 最容易犯的错,无限递归
这个坑我从第一次写 property 就踩了,而且报错信息长到让人发懵。
>>> class Bad:
… @property
… def x(self):
… return self._x
… @x.setter
… def x(self, value):
… self.x = value
…
>>> b = Bad()
>>> b.x = 1
RecursionError: maximum recursion depth exceeded
看 setter 那一行,self.x = value。可 x 已经是 property 了,给 self.x 赋值就等于又调了一次这个 setter,然后它又给 self.x 赋值,无限循环,直到 Python 的递归上限把它掐断。
b.x = 1
│
└─► x.setter(self, 1)
│
└─► self.x = 1 ← 又是赋值,又调 setter
│
└─► x.setter(self, 1) → ……无止境
修法一句话,setter 里必须赋值给另一个名字,通常是加下划线的那个。
>>> class Good:
… @property
… def x(self):
… return self._x
… @x.setter
… def x(self, value):
… self._x = value
…
>>> g = Good()
>>> g.x = 1
>>> g.x
1
>>> g.__dict__
{'_x': 1}
记住一条铁律,@property 暴露出来的属性名,和内部真正存值的变量名,绝对不能是同一个。一旦同名,getter 或 setter 就会调用它自己。
再补一句,如果你在调试时看到 RecursionError,第一件事就去查 property 的 getter 和 setter 里有没有写错变量名。
4. 继承
4.1 先看没有继承的时候有多啰嗦
要写一个猫类和一个狗类,它们都有名字、都会睡、都会吃,只有叫声不一样。
>>> class Dog:
… def __init__(self, name):
… self.name = name
… def sleep(self):
… print(self.name, "趴着睡")
… def eat(self, food):
… print(self.name, "吃", food)
… def speak(self):
… return "汪汪"
…
>>> class Cat:
… def __init__(self, name):
… self.name = name
… def sleep(self):
… print(self.name, "趴着睡")
… def eat(self, food):
… print(self.name, "吃", food)
… def speak(self):
… return "喵喵"
…
__init__、sleep、eat 三个方法一模一样,抄了两遍。等要加个鸟类,再抄第三遍。等要改 sleep 的逻辑,三个类挨个改。
继承就是把重复的部分向上抽。
Animal ← 父类,放共同的部分
name / sleep / eat
│
┌──────┼──────┐
▼ ▼ ▼
Dog Cat Duck ← 子类,只写自己不一样的
speak speak speak
4.2 继承的写法,括号里写父类
>>> class Animal:
… def __init__(self, name):
… self.name = name
… def sleep(self):
… print(self.name, "趴着睡")
… def eat(self, food):
… print(self.name, "吃", food)
…
>>> class Dog(Animal):
… pass
…
>>> d = Dog("小黑")
>>> d.name
'小黑'
>>> d.sleep()
小黑 趴着睡
>>> d.eat("骨头")
小黑 吃 骨头
class Dog(Animal) 这一句,Dog 就把 Animal 里的东西全拿了过来。pass 表示暂时什么都不加,一个方法没写,却已经能用父类的三个方法。
查一下继承关系。
>>> Dog.__bases__
(<class '__main__.Animal'>,)
>>> Animal.__bases__
(<class 'object'>,)
前面那个 __main__ 是模块名,在交互窗口里敲代码就属于 __main__ 模块,写进 .py 文件再跑,显示的会是文件名。
最后那行值得留意,我们自己写的 Animal 也是有父类的,它的父类叫 object。Python 里所有类最终都继承到 object,它是老祖宗。
4.3 子类加自己的东西
在子类里写新方法,属于它独有。
>>> class Dog(Animal):
… def speak(self):
… return self.name + " 汪汪"
… def fetch(self):
… return self.name + " 把球叼回来了"
…
>>> d = Dog("小黑")
>>> d.speak()
'小黑 汪汪'
>>> d.fetch()
'小黑 把球叼回来了'
>>> d.sleep()
小黑 趴着睡
自己的方法和继承来的方法混着用,调用的时候看不出来哪个是哪层的,这正是继承想要的透明感。
4.4 方法重写,子类说了算
父类的方法不合用,子类可以照着名字重写一个。
>>> class Cat(Animal):
… def sleep(self):
… print(self.name, "蜷在猫窝里睡")
… def speak(self):
… return self.name + " 喵喵"
…
>>> c = Cat("小白")
>>> c.sleep()
小白 蜷在猫窝里睡
>>> d = Dog("小黑")
>>> d.sleep()
小黑 趴着睡
同样是 sleep(),猫和狗的表现不一样。查找的顺序跟属性一样,先在子类找,找不到才去父类。
c.sleep()
│
├─ 查 Cat 里有没有 sleep → 有,用它
│
└─(如果没有)再查 Animal
4.5 super(),把父类的方法叫回来
重写的时候经常遇到的情况是,父类那个方法大部分还要用,只是想前后加两句。这时候别把代码抄一遍,用 super()。
>>> class Dog(Animal):
… def sleep(self):
… print("先转三圈")
… super().sleep()
… print("睡着了")
…
>>> d = Dog("小黑")
>>> d.sleep()
先转三圈
小黑 趴着睡
睡着了
super().sleep() 就是"调用父类的 sleep"。它执行完会继续执行后面的语句,所以顺序是前、中、后。
在 __init__ 里用得最多。父类已经把通用字段绑好了,子类只需要绑自己多出来的那几个。
>>> class Employee:
… def __init__(self, name, salary):
… self.name = name
… self.salary = salary
… def info(self):
… return "%s 月薪 %d" % (self.name, self.salary)
…
>>> class Manager(Employee):
… def __init__(self, name, salary, bonus):
… super().__init__(name, salary)
… self.bonus = bonus
… def info(self):
… return super().info() + " 加奖金 %d,共 %d" % (
… self.bonus, self.salary + self.bonus)
…
>>> m = Manager("张三", 20000, 5000)
>>> print(m.info())
张三 月薪 20000 加奖金 5000,共 25000
super().__init__(name, salary) 一行顶替了两条赋值,而且将来父类加了新字段,子类不用动。
要是忘了写 super,会这样。
>>> class BadManager(Employee):
… def __init__(self, name, salary, bonus):
… self.bonus = bonus
…
>>> bm = BadManager("李四", 10000, 2000)
>>> bm.bonus
2000
>>> bm.name
AttributeError: 'BadManager' object has no attribute 'name'
子类定义了 __init__,就把父类的完全盖住了,父类的初始化一句没执行,name 和 salary 根本没绑上。这个报错在新手写继承时特别常见,只要子类写了 __init__,第一件事就是 super().__init__()。
super() 还有个老写法,把父类名写出来。
>>> class OldStyle(Employee):
… def __init__(self, name, salary, bonus):
… Employee.__init__(self, name, salary)
… self.bonus = bonus
…
>>> o = OldStyle("王五", 8000, 1000)
>>> print(o.info())
王五 月薪 8000
能用,但父类名写死在里面,改个继承关系就得跟着改。新代码一律用 super()。
4.6 isinstance 和 issubclass,判断关系用
想知道一个对象是不是某个类的,用 isinstance。
>>> isinstance(d, Dog)
True
>>> isinstance(d, Animal)
True
>>> isinstance(d, object)
True
>>> isinstance(d, Cat)
False
狗是狗,狗也是动物,狗当然是 object。
判断类之间的关系用 issubclass。
>>> issubclass(Dog, Animal)
True
>>> issubclass(Animal, Dog)
False
>>> issubclass(Dog, Dog)
True
有个容易混的地方,isinstance 会把继承关系算进去,而 type(x) is SomeClass 不会。
>>> type(d) is Dog
True
>>> type(d) is Animal
False
>>> type(d) is type(d)
True
想判断"它到底是哪个类造出来的"用 type,想判断"它能不能当动物使"用 isinstance。日常业务代码里后者用得多,因为它对继承友好。
4.7 多继承,一个类能认好几个爹
Python 允许一个类同时继承多个父类。
>>> class Flyable:
… def fly(self):
… return "我能飞"
…
>>> class Swimmable:
… def swim(self):
… return "我能游"
…
>>> class Duck(Animal, Flyable, Swimmable):
… def speak(self):
… return self.name + " 嘎嘎"
…
>>> duck = Duck("唐老鸭")
>>> duck.speak()
'唐老鸭 嘎嘎'
>>> duck.fly()
'我能飞'
>>> duck.swim()
'我能游'
>>> duck.sleep()
唐老鸭 趴着睡
三份能力拼一起。这种把一个个小能力拼进来的写法叫 Mixin,实际项目里很常见。
麻烦出在多个父类有同名方法的时候,到底听谁的。Python 用一套叫 MRO 的规则来排顺序。
A
/ \\
B C
\\ /
D ← D 同时继承 B 和 C,B 和 C 又都继承 A
这种结构叫菱形继承,是 MRO 存在的理由。看个例子。
>>> class A:
… def hi(self):
… print("A")
…
>>> class B(A):
… def hi(self):
… print("B")
… super().hi()
…
>>> class C(A):
… def hi(self):
… print("C")
… super().hi()
…
>>> class D(B, C):
… def hi(self):
… print("D")
… super().hi()
…
>>> d = D()
>>> d.hi()
D
B
C
A
打印顺序 D → B → C → A。这个顺序不是拍脑袋定的,就是 MRO。
>>> D.__mro__
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
__mro__ 把这个类的查找顺序摊给你看。注意 A 只出现了一次,而且排在 B 和 C 后面,不会出现 A 的 hi 被调用两遍的情况。
每个方法里都写了 super().hi(),这就是协作式调用,它让同一条链上的每个类都有机会执行一次。少写任何一个 super(),链条就断在那里。
多继承用得好是利器,用不好是灾难。我给自己定的规矩是,继承层级别超过两层,能用组合就别用多继承。真要用,父类最好都是那种只有一两个方法的纯能力类。
4.8 多态,同一个动作不同的表现
有了继承和方法重写,多态就自然冒出来了。
>>> class Animal:
… def __init__(self, name):
… self.name = name
… def speak(self):
… return "……"
…
>>> class Dog(Animal):
… def speak(self):
… return self.name + " 汪汪"
…
>>> class Cat(Animal):
… def speak(self):
… return self.name + " 喵喵"
…
>>> class Duck(Animal):
… def speak(self):
… return self.name + " 嘎嘎"
…
>>> for a in [Dog("小黑"), Cat("小白"), Duck("唐老鸭")]:
… print(a.speak())
小黑 汪汪
小白 喵喵
唐老鸭 嘎嘎
循环那一行完全不关心列表里到底是猫是狗,它只管喊 speak(),每个对象自己知道该怎么叫。不用写 if isinstance(a, Dog) 再分支,加一种新动物,这段循环一个字都不用改。
Python 在这件事上比别的语言更松,它还有个说法叫鸭子类型,走起来像鸭子、叫起来像鸭子,那它就是鸭子。压根不继承同一个父类也行。
>>> class Robot:
… def __init__(self, name):
… self.name = name
… def speak(self):
… return self.name + " 滴,收到"
…
>>> for a in [Dog("小黑"), Cat("小白"), Robot("铁蛋")]:
… print(a.speak())
小黑 汪汪
小白 喵喵
铁蛋 滴,收到
Robot 跟 Animal 一点关系没有,只要它有 speak 方法,就能进这个循环。这种"按能力而不是按血统"的默契,是 Python 代码写起来松快的原因之一。
4.9 私有属性和继承,一个反直觉的细节
前面说过 __ 开头的属性会被改名。它在继承里的影响是这样的。
>>> class Base:
… def __init__(self):
… self.__value = 1
… def get(self):
… return self.__value
…
>>> class Sub(Base):
… def get(self):
… return self.__value
…
>>> s = Sub()
>>> s.get()
AttributeError: 'Sub' object has no attribute '_Sub__value'
Sub 里那个 self.__value 会被改写成 self._Sub__value,而父类创建的是 _Base__value,两个名字对不上,所以取不到。
这其实是有意为之,双下划线的作用之一就是防止子类不小心覆盖父类的内部属性。想让子类访问,改成单下划线 _value 就行,那只是个约定,不做改名。
>>> class Base:
… def __init__(self):
… self._value = 1
… def get(self):
… return self._value
…
>>> class Sub(Base):
… def get(self):
… return self._value * 10
…
>>> s = Sub()
>>> s.get()
10
4.10 继承这块我踩过的坑
坑一,子类忘了调 super().__init__()。 4.5 演示过,症状是父类属性访问不到。
坑二,继承用在了不该用的地方。 判断标准就一句话,看两者是"是一种"还是"有一个"。狗是一种动物,用继承;汽车有一个发动机,用组合。
>>> class Engine:
… def start(self):
… return "引擎启动"
…
>>> class Car:
… def __init__(self, brand):
… self.brand = brand
… self.engine = Engine()
… def start(self):
… return "%s %s" % (self.brand, self.engine.start())
…
>>> car = Car("比亚迪")
>>> car.start()
'比亚迪 引擎启动'
Car 没有继承 Engine,而是把发动机装在身上了。写成 class Car(Engine) 也能跑,但语义就成了"汽车是一种发动机",听着就不对。而且组合的好处是,换一个混动发动机不用动 Car 的代码。
坑三,继承链太深。 我接手过一个项目,A → B → C → D → E 五层,想搞清楚某个方法最终在哪实现的,得顺着 __mro__ 一路点进去,改一行要担心四个父类。超过两层就该停下来想想是不是该拆成组合。
坑四,多继承时忘了写 super()。 链条断在中间,后面的类不会被执行,而且不报错,只是少干了活。这种 bug 特别难查。
5. 实战
四个例子,从封装到继承,把前面几节串起来,建议自己敲一遍。
5.1 实战一,银行账户系统
需求。账户要有户主和余额,只能存、取、转账,每次操作留流水,余额不能从外面直接改。
思路。
BankAccount
├── 类属性 bank_name、total ← 全行共用
├── 实例属性 __balance、__logs ← 双下划线,外面碰不到
├── property balance(只读)、logs(返回元组)
└── 方法 deposit、withdraw、transfer
代码。
class BankAccount:
"""银行账户,余额只能通过存取款方法改动"""
bank_name = "派森银行"
total = 0
def __init__(self, owner, opening=0):
self.owner = owner
self.__balance = 0
self.__logs = []
BankAccount.total += 1
if opening > 0:
self.deposit(opening)
@property
def balance(self):
"""余额,只读"""
return self.__balance
@property
def logs(self):
"""流水,返回元组防止外面改"""
return tuple(self.__logs)
def deposit(self, amount):
"""存钱"""
if amount <= 0:
raise ValueError("存入金额必须大于 0,收到 %s" % amount)
self.__balance += amount
self.__logs.append("存入 %.2f,余额 %.2f" % (amount, self.__balance))
return self.__balance
def withdraw(self, amount):
"""取钱"""
if amount <= 0:
raise ValueError("取款金额必须大于 0,收到 %s" % amount)
if amount > self.__balance:
raise ValueError("余额不足,当前 %.2f 想取 %.2f" % (self.__balance, amount))
self.__balance -= amount
self.__logs.append("取出 %.2f,余额 %.2f" % (amount, self.__balance))
return self.__balance
def transfer(self, other, amount):
"""转账给另一个账户"""
self.withdraw(amount)
other.deposit(amount)
self.__logs.append("转给 %s %.2f" % (other.owner, amount))
other.__logs.append("收到 %s 转来 %.2f" % (self.owner, amount))
return self.__balance
def __str__(self):
return "%s 的账户,余额 %.2f" % (self.owner, self.__balance)
zhang = BankAccount("张三", 1000)
li = BankAccount("李四", 500)
print("开户行", BankAccount.bank_name, "已开账户数", BankAccount.total)
print(zhang)
print(li)
zhang.withdraw(200)
zhang.transfer(li, 300)
print("\\n— 交易之后的余额")
print(zhang)
print(li)
print("\\n— 张三的流水")
for line in zhang.logs:
print(" ", line)
print("\\n— 李四的流水")
for line in li.logs:
print(" ", line)
print("\\n余额是只读的,直接改会报错")
try:
zhang.balance = 999999
except AttributeError as e:
print(" AttributeError:", e)
try:
zhang.withdraw(99999)
except ValueError as e:
print(" ", type(e).__name__ + ":", e)
print("\\n流水返回的是元组,改不动")
print(" ", type(zhang.logs).__name__)
输出。
开户行 派森银行 已开账户数 2
张三 的账户,余额 1000.00
李四 的账户,余额 500.00
— 交易之后的余额
张三 的账户,余额 500.00
李四 的账户,余额 800.00
— 张三的流水
存入 1000.00,余额 1000.00
取出 200.00,余额 800.00
取出 300.00,余额 500.00
转给 李四 300.00
— 李四的流水
存入 500.00,余额 500.00
存入 300.00,余额 800.00
收到 张三 转来 300.00
余额是只读的,直接改会报错
AttributeError: property 'balance' of 'BankAccount' object has no setter
ValueError: 余额不足,当前 500.00 想取 99999.00
流水返回的是元组,改不动
tuple
复盘。
balance 是只读 property,外面读得到改不动,改余额只有存取款和转账三条路,每条路都有校验。这就是封装最实在的好处,钱这个东西不该敞着。
logs 返回的是 tuple(self.__logs) 而不是列表本身。直接把列表交出去的话,外面拿到引用就能随便 append 伪造流水,转成元组堵住了这条路。这也是我处理"内部有可变数据但要暴露出去"时的常用手法。
transfer 里能写 other.__logs,因为双下划线改名是按类名走的,BankAccount 内部访问任何 BankAccount 对象的 __logs 都合法。同类之间不设防,防的是外面的代码。
BankAccount.total += 1 写的是类名。写完这一行再看 2.6 那段,为什么不能写 self.total += 1,那里的解释在这儿有了实际场景。
5.2 实战二,电商购物车
需求。往购物车加商品、改数量、删行,自动算折扣,打小票。
思路。三个类各管一层,典型的组合关系。
Cart ──包含──► CartItem ──引用──► Goods
│ │ │
│ owner │ goods / count │ name / price / unit
│ __items │ subtotal(property)
│ total / discount / payable(property)
│ receipt()
代码。
import unicodedata
def pad(text, width):
"""按实际显示宽度补空格,中文算两列"""
shown = 0
for ch in str(text):
shown += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
return str(text) + " " * max(0, width – shown)
DISCOUNT_RULES = [
(200, 0.95),
(500, 0.90),
(1000, 0.85),
]
class Goods:
"""商品"""
__slots__ = ("name", "price", "unit")
def __init__(self, name, price, unit="件"):
self.name = name
self.price = price
self.unit = unit
def __str__(self):
return "%s %.2f元/%s" % (self.name, self.price, self.unit)
__repr__ = __str__
class CartItem:
"""购物车里的一行,商品加数量"""
def __init__(self, goods, count=1):
self.goods = goods
self.count = count
@property
def subtotal(self):
"""小计,现算的"""
return round(self.goods.price * self.count, 2)
def __str__(self):
return pad(self.goods.name, 14) + pad("x" + str(self.count), 6) + "%8.2f" % self.subtotal
class Cart:
"""购物车"""
def __init__(self, owner):
self.owner = owner
self.__items = []
def add(self, goods, count=1):
"""加购,已有就累加数量"""
for item in self.__items:
if item.goods.name == goods.name:
item.count += count
return
self.__items.append(CartItem(goods, count))
def remove(self, name, count=None):
"""减数量,不传 count 就整行删掉"""
for i, item in enumerate(self.__items):
if item.goods.name == name:
if count is None or count >= item.count:
self.__items.pop(i)
else:
item.count -= count
return True
return False
@property
def items(self):
return tuple(self.__items)
@property
def kinds(self):
"""有几种商品"""
return len(self.__items)
@property
def quantity(self):
"""一共多少件"""
return sum(item.count for item in self.__items)
@property
def total(self):
"""原价合计"""
return round(sum(item.subtotal for item in self.__items), 2)
@property
def discount(self):
"""按金额自动打折"""
rate = 1.0
for limit, value in DISCOUNT_RULES:
if self.total >= limit:
rate = value
return rate
@property
def payable(self):
"""应付"""
return round(self.total * self.discount, 2)
def receipt(self):
"""打小票"""
print("=" * 46)
print("买家 %s" % self.owner)
print("=" * 46)
print(" " + pad("商品", 14) + pad("数量", 6) + pad("小计", 8))
for item in self.__items:
print(" " + str(item))
print("-" * 46)
print("共 %d 种 %d 件,原价 %.2f 元" % (self.kinds, self.quantity, self.total))
print("折扣 %.2f,应付 %.2f 元,省 %.2f 元"
% (self.discount, self.payable, self.total – self.payable))
print("=" * 46)
catalog = [
Goods("机械键盘", 299, "个"),
Goods("无线鼠标", 89, "个"),
Goods("显示器", 1299, "台"),
Goods("USB 线", 19.9, "根"),
]
cart = Cart("张三")
cart.add(catalog[0])
cart.add(catalog[1], 2)
cart.add(catalog[3], 3)
cart.receipt()
print("\\n再加一台显示器,看看折扣变化")
cart.add(catalog[2])
cart.receipt()
print("\\n删掉 2 根 USB 线")
cart.remove("USB 线", 2)
print("现在 %d 种 %d 件,应付 %.2f" % (cart.kinds, cart.quantity, cart.payable))
print("\\n购物车里装的是对象")
print(" ", cart.items[0].goods, "单价", cart.items[0].goods.price)
输出。
==============================================
买家 张三
==============================================
商品 数量 小计
机械键盘 x1 299.00
无线鼠标 x2 178.00
USB 线 x3 59.70
———————————————-
共 3 种 6 件,原价 536.70 元
折扣 0.90,应付 483.03 元,省 53.67 元
==============================================
再加一台显示器,看看折扣变化
==============================================
买家 张三
==============================================
商品 数量 小计
机械键盘 x1 299.00
无线鼠标 x2 178.00
USB 线 x3 59.70
显示器 x1 1299.00
———————————————-
共 4 种 7 件,原价 1835.70 元
折扣 0.85,应付 1560.35 元,省 275.35 元
==============================================
删掉 2 根 USB 线
现在 4 种 5 件,应付 1526.52
购物车里装的是对象
机械键盘 299.00元/个 单价 299
复盘。
Goods 用了 __slots__,商品字段就三个,不允许乱加。对象多的场景省内存,也堵住了拼错属性名。
subtotal、total、discount、payable 全是 property,没有一个在内存里存值,全是读的时候现算。好处是改数量、改折扣之后不用手动同步,读到的永远是最新结果。这是 property 最适合的地方。
discount 那个实现有点意思,它从 DISCOUNT_RULES 里顺序查,最后的规则覆盖前面的,所以满了 1000 就打 85 折。规则写在类外面,改折扣不用动类。
pad 这个函数是第六章实战一那位的老朋友,中文占两列宽度,%-14s 排出来的表是歪的。命令行里打中文报表躲不开它。
cart.items[0].goods 这行是组合的样子,购物车不存商品的名字和价格,它存的是商品对象本身。将来商品要加库存、分类、图片,CartItem 一行都不用改。
5.3 实战三,图形面积计算器
需求。支持圆、矩形、三角形,能算面积和周长,能放进同一个列表里统一处理,边长参数要校验。
思路。
Shape(基类,只定接口)
area() / perimeter() / describe()
│
┌──────┼─────────┐
▼ ▼ ▼
Circle Rectangle Triangle
radius w/h 三边
代码。
import math
import unicodedata
def pad(text, width):
"""按实际显示宽度补空格,中文算两列"""
shown = 0
for ch in str(text):
shown += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
return str(text) + " " * max(0, width – shown)
class Shape:
"""图形基类,只定接口,不实现"""
def area(self):
raise NotImplementedError("子类必须实现 area")
def perimeter(self):
raise NotImplementedError("子类必须实现 perimeter")
def describe(self):
return "%s 面积 %.2f 周长 %.2f" % (self.__class__.__name__, self.area(), self.perimeter())
class Circle(Shape):
def __init__(self, radius):
if radius <= 0:
raise ValueError("半径必须大于 0")
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radius
def __str__(self):
return "圆 r=%g" % self.radius
class Rectangle(Shape):
def __init__(self, width, height):
if width <= 0 or height <= 0:
raise ValueError("长宽必须大于 0")
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
@property
def is_square(self):
return self.width == self.height
def __str__(self):
return "矩形 %gx%g" % (self.width, self.height)
class Triangle(Shape):
def __init__(self, a, b, c):
sides = sorted([a, b, c])
if sides[0] <= 0:
raise ValueError("边长必须大于 0")
if sides[0] + sides[1] <= sides[2]:
raise ValueError("两边之和必须大于第三边")
self.a, self.b, self.c = a, b, c
def area(self):
"""海伦公式"""
s = self.perimeter() / 2
return math.sqrt(s * (s – self.a) * (s – self.b) * (s – self.c))
def perimeter(self):
return self.a + self.b + self.c
def __str__(self):
return "三角形 %g/%g/%g" % (self.a, self.b, self.c)
def total_area(shapes):
"""不管什么图形,有 area 就能算"""
return round(sum(s.area() for s in shapes), 2)
shapes = [
Circle(3),
Rectangle(4, 6),
Rectangle(5, 5),
Triangle(3, 4, 5),
]
print("=" * 52)
print(pad("图形", 18) + pad("面积", 10) + pad("周长", 10))
print("=" * 52)
for s in shapes:
print(pad(s, 18) + pad("%.2f" % s.area(), 10) + pad("%.2f" % s.perimeter(), 10))
print("=" * 52)
print("合计面积 %.2f" % total_area(shapes))
print("\\n正方形判断", [str(s) for s in shapes if isinstance(s, Rectangle) and s.is_square])
print("\\n按面积排序")
for s in sorted(shapes, key=lambda x: x.area(), reverse=True):
print(" " + pad(s, 16) + "%10.2f" % s.area())
print("\\n没实现接口的话")
try:
Shape().area()
except NotImplementedError as e:
print(" NotImplementedError:", e)
print("\\n参数校验")
for bad in [lambda: Circle(-1), lambda: Rectangle(0, 5), lambda: Triangle(1, 2, 5)]:
try:
bad()
except ValueError as e:
print(" ValueError:", e)
输出。
====================================================
图形 面积 周长
====================================================
圆 r=3 28.27 18.85
矩形 4×6 24.00 20.00
矩形 5×5 25.00 20.00
三角形 3/4/5 6.00 12.00
====================================================
合计面积 83.27
正方形判断 ['矩形 5×5']
按面积排序
圆 r=3 28.27
矩形 5×5 25.00
矩形 4×6 24.00
三角形 3/4/5 6.00
没实现接口的话
NotImplementedError: 子类必须实现 area
参数校验
ValueError: 半径必须大于 0
ValueError: 长宽必须大于 0
ValueError: 两边之和必须大于第三边
复盘。
Shape 是个纯接口类,area 和 perimeter 里直接 raise NotImplementedError。这么写的意思是"子类必须自己实现,忘了就当场报错",比留个空函数返回 0 强得多,返回 0 的话算出来的总面积会悄悄少一块。
total_area 和排序那两处是全篇最值钱的地方。这两个函数从头到尾没写过 isinstance,它们只关心对象有没有 area。将来加个 Ellipse 类,只要实现了 area,直接塞进列表就能算,这两行代码一个字都不用改。这就是多态。
Triangle 的 area 用了海伦公式,内部调了 self.perimeter()。子类内部调用自己的方法照样走多态,将来有人继承 Triangle 改了周长算法,面积会自动跟着变。
校验放在 __init__ 里而不是 area 里,理由很实在,边长不合法的对象压根不该被造出来,早报错比晚报错好查。
5.4 实战四,学生管理系统,把函数版重写成类
第一章到第六章我们一直在用列表装元组处理学生数据。这一节把它整个翻成类。
需求。学生有姓名、性别、分数,分数要校验、等级要自动算;班级能加人删人查人,能给平均分、及格率、排名、等级分组。
代码。
import unicodedata
def pad(text, width):
"""按实际显示宽度补空格,中文算两列"""
shown = 0
for ch in str(text):
shown += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
return str(text) + " " * max(0, width – shown)
class Student:
"""学生,分数受 property 保护"""
__slots__ = ("name", "_score", "gender")
def __init__(self, name, score, gender="未知"):
self.name = name
self.gender = gender
self.score = score
@property
def score(self):
return self._score
@score.setter
def score(self, value):
if not isinstance(value, int):
raise TypeError("分数必须是整数,收到 %r" % value)
if not 0 <= value <= 100:
raise ValueError("分数要在 0 到 100 之间,收到 %d" % value)
self._score = value
@property
def grade(self):
"""等级,按分数现算"""
if self._score >= 90:
return "A"
if self._score >= 80:
return "B"
if self._score >= 60:
return "C"
return "D"
@property
def passed(self):
return self._score >= 60
def __str__(self):
return "%s(%s)%d 分 %s 等" % (self.name, self.gender, self._score, self.grade)
__repr__ = __str__
class ClassRoom:
"""班级,装一堆 Student"""
def __init__(self, title):
self.title = title
self.__students = []
def add(self, student):
if not isinstance(student, Student):
raise TypeError("只能加 Student 对象,收到 %s" % type(student).__name__)
self.__students.append(student)
def find(self, name):
for stu in self.__students:
if stu.name == name:
return stu
return None
def remove(self, name):
stu = self.find(name)
if stu is None:
return False
self.__students.remove(stu)
return True
@property
def students(self):
return tuple(self.__students)
def __len__(self):
return len(self.__students)
def __iter__(self):
return iter(self.__students)
@property
def average(self):
if not self.__students:
return 0.0
return round(sum(s.score for s in self.__students) / len(self.__students), 1)
@property
def pass_rate(self):
if not self.__students:
return 0.0
n = sum(1 for s in self.__students if s.passed)
return round(n / len(self.__students) * 100, 1)
def ranking(self):
"""按分数从高到低排"""
return sorted(self.__students, key=lambda s: s.score, reverse=True)
def by_grade(self):
"""按等级分组"""
groups = {}
for stu in self.__students:
groups.setdefault(stu.grade, []).append(stu)
return groups
def report(self):
"""打印成绩单"""
print("=" * 54)
print("%s 共 %d 人 平均分 %.1f 及格率 %.1f%%"
% (self.title, len(self), self.average, self.pass_rate))
print("=" * 54)
print(pad("姓名", 10) + pad("性别", 8) + pad("分数", 8) + "等级")
print("-" * 54)
for stu in self.ranking():
print(pad(stu.name, 10) + pad(stu.gender, 8)
+ pad(str(stu.score), 8) + stu.grade)
print("-" * 54)
for grade in sorted(self.by_grade()):
members = self.by_grade()[grade]
print("%s 等 %d 人 %s" % (grade, len(members),
"、".join(s.name for s in members)))
print("=" * 54)
klass = ClassRoom("高二三班")
for name, score, gender in [
("张三", 88, "男"),
("李四", 95, "女"),
("王五", 72, "男"),
("赵六", 58, "女"),
("孙七", 90, "男"),
("周八", 66, "女"),
]:
klass.add(Student(name, score, gender))
klass.report()
print("\\n直接遍历对象,因为实现了 __iter__")
for stu in klass:
print(" ", stu)
print("\\n找人")
stu = klass.find("王五")
print(" ", stu)
print(" 给他加 5 分")
stu.score = 77
print(" ", stu, "等级", stu.grade)
print("\\n分数校验拦得住脏数据")
for bad in [105, -3, "九十"]:
try:
stu.score = bad
except (ValueError, TypeError) as e:
print(" %s: %s" % (type(e).__name__, e))
print("\\n转学走一个")
klass.remove("赵六")
print(" 现在 %d 人,平均分 %.1f,及格率 %.1f%%"
% (len(klass), klass.average, klass.pass_rate))
print("\\n加错类型会被拦下")
try:
klass.add(("钱九", 80))
except TypeError as e:
print(" TypeError:", e)
输出。
======================================================
高二三班 共 6 人 平均分 78.2 及格率 83.3%
======================================================
姓名 性别 分数 等级
——————————————————
李四 女 95 A
孙七 男 90 A
张三 男 88 B
王五 男 72 C
周八 女 66 C
赵六 女 58 D
——————————————————
A 等 2 人 李四、孙七
B 等 1 人 张三
C 等 2 人 王五、周八
D 等 1 人 赵六
======================================================
直接遍历对象,因为实现了 __iter__
张三(男)88 分 B 等
李四(女)95 分 A 等
王五(男)72 分 C 等
赵六(女)58 分 D 等
孙七(男)90 分 A 等
周八(女)66 分 C 等
找人
王五(男)72 分 C 等
给他加 5 分
王五(男)77 分 C 等 等级 C
分数校验拦得住脏数据
ValueError: 分数要在 0 到 100 之间,收到 105
ValueError: 分数要在 0 到 100 之间,收到 -3
TypeError: 分数必须是整数,收到 '九十'
转学走一个
现在 5 人,平均分 83.2,及格率 100.0%
加错类型会被拦下
TypeError: 只能加 Student 对象,收到 tuple
复盘。
对比一下开头那个用元组的版本。字段从 row[0] row[1] 变成了 stu.name stu.score,含义写在代码里,不靠约定。
分数校验这次是硬的。Student("张三", 999) 在构造时就会炸,脏数据进不了系统。而元组版本里,999 分能一路走到打印环节,最后以"平均分 1200"这种荒谬数字出现在报表上。
grade 和 passed 都是现算的 property,改了分数等级立刻变,不存在"分数改了等级没同步"这种事。
__len__ 和 __iter__ 这两个特殊方法让 ClassRoom 用起来像个真的容器,len(klass)、for stu in klass 直接就能写。刚学的时候觉得这俩没用,写到第二个管理类就知道有多省事。
add 里那句类型检查看着多余,实际救过我。曾经往班级里塞了个字典,报错报在排序那一行,错误信息是 '>' not supported between instances of 'dict' and 'Student',从这行往上找了十分钟才定位到是加人的时候塞错了。现在直接拦在入口,报错信息清楚明白。
6. 小结
6.1 知识清单
面向对象概述
- 面向过程按步骤拆,面向对象按"东西"拆
- 类是图纸,对象是按图纸造出来的实体,实例就是对象
- 三大特征,封装管数据安全、继承管复用、多态管统一调用
- 别为了用类而用类,几十行的一次性脚本用函数就够了
类的定义和使用
- class 类名: 定义,类名用大驼峰
- __init__ 是初始化,__new__ 才是创建
- self 是对象自己,Python 自动传入,漏写会报 takes 0 positional arguments but 1 was given
- 类属性全类共用,实例属性各对象一份;可变对象当类属性会被所有对象共享
- __str__ 面向用户,__repr__ 面向开发者
- __slots__ 能限制属性、省内存
- 双下划线是名字改写不是真私有,单下划线是约定
- @staticmethod 不收 self,@classmethod 收 cls 常用来做工厂方法
属性 property
- @property 装饰读,@x.setter 装饰写,@x.deleter 装饰删
- 最大的用处是把裸属性升级成受控属性,调用方不用改
- 只写 getter 就是只读属性
- 属性名和内部变量名不能同名,同名会无限递归
继承
- class 子类(父类),子类自动拥有父类的属性和方法
- 重写就是子类写个同名方法盖住父类的
- super() 调父类方法,子类写了 __init__ 就必须 super().__init__()
- isinstance 认继承关系,type(x) is C 不认
- MRO 决定多继承的查找顺序,__mro__ 能查
- 判断该不该继承,看是"是一种"还是"有一个"
6.2 避坑清单
| 1 | 可变对象当类属性 | 改一个对象,其他对象跟着变 |
| 2 | property 的 getter/setter 里用了同名属性 | RecursionError |
| 3 | 子类 __init__ 忘了 super() | 父类属性访问不到 |
| 4 | 方法漏写 self | takes 0 positional arguments but 1 was given |
| 5 | 拿类名访问实例属性 | AttributeError: type object |
| 6 | 计数器写成了 self.total += 1 | 类属性永远是 0 |
| 7 | 拿对象直接调用却期望打印好看 | 没写 __str__,打出一串地址 |
| 8 | 继承层级太深 | 找个方法要翻四层父类 |
| 9 | 多继承漏写 super() | 链条断了,不报错只是少干活 |
| 10 | 拼错属性名 | 不报错,凭空多一个属性 |
前两条是这一章最该记住的。第一条跟第六章默认参数的坑是同一个病因,第二条我到现在偶尔还会手滑写出。
6.3 五道题
第一题,写一个 Rectangle 类,有宽和高,用 property 提供只读的 area 和 perimeter,长宽赋值时校验必须大于 0。
第二题,写一个 Counter 类,用类属性统计一共创建了多少个实例,实例方法 hit() 每次调用把自己的计数加一。
第三题,写 Animal、Dog、Cat 三个类,Dog 和 Cat 重写 speak(),再写个函数接收一个动物列表,依次打印它们的叫声,函数里不许出现 isinstance。
第四题,下面这段代码有什么问题,怎么改?
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@property
def celsius(self):
return self.celsius
@celsius.setter
def celsius(self, value):
self.celsius = value
第五题,写一个 Stack 类,实现 push、pop、is_empty,再加 __len__ 和 __str__,让它能 len(s) 和 print(s)。
参考答案
第一题,长宽校验写在 setter 里,面积周长只读。
>>> class Rectangle:
… def __init__(self, width, height):
… self.width = width
… self.height = height
… @property
… def width(self):
… return self._width
… @width.setter
… def width(self, value):
… if value <= 0:
… raise ValueError("宽必须大于 0")
… self._width = value
… @property
… def height(self):
… return self._height
… @height.setter
… def height(self, value):
… if value <= 0:
… raise ValueError("高必须大于 0")
… self._height = value
… @property
… def area(self):
… return self._width * self._height
… @property
… def perimeter(self):
… return 2 * (self._width + self._height)
…
>>> r = Rectangle(3, 4)
>>> print(r.area, r.perimeter)
12 14
>>> r.width = 5
>>> r.area
20
>>> Rectangle(0, 4)
ValueError: 宽必须大于 0
第二题,类属性配类名自增。
>>> class Counter:
… total = 0
… def __init__(self, name):
… self.name = name
… self.count = 0
… Counter.total += 1
… def hit(self):
… self.count += 1
… return self.count
…
>>> a = Counter("a")
>>> b = Counter("b")
>>> a.hit()
1
>>> a.hit()
2
>>> b.hit()
1
>>> Counter.total
2
第三题,多态,函数里只有 speak()。
>>> class Animal:
… def __init__(self, name):
… self.name = name
… def speak(self):
… return "……"
…
>>> class Dog(Animal):
… def speak(self):
… return self.name + ":汪汪"
…
>>> class Cat(Animal):
… def speak(self):
… return self.name + ":喵喵"
…
>>> def chorus(animals):
… for one in animals:
… print(one.speak())
…
>>> chorus([Dog("小黑"), Cat("小白")])
小黑:汪汪
小白:喵喵
第四题,celsius 属性和内部变量同名了。读 self.celsius 会去调 getter,getter 又读 self.celsius,无限递归。改法是把内部变量换成 _celsius。
>>> class Temperature:
… def __init__(self, celsius):
… self.celsius = celsius
… @property
… def celsius(self):
… return self._celsius
… @celsius.setter
… def celsius(self, value):
… self._celsius = value
… @property
… def fahrenheit(self):
… return self._celsius * 1.8 + 32
…
>>> t = Temperature(100)
>>> print(t.celsius, t.fahrenheit)
100 212.0
第五题,__len__ 和 __str__ 让对象用起来像内置容器。
>>> class Stack:
… def __init__(self):
… self.__items = []
… def push(self, item):
… self.__items.append(item)
… def pop(self):
… if not self.__items:
… raise IndexError("栈空了")
… return self.__items.pop()
… def is_empty(self):
… return len(self.__items) == 0
… def __len__(self):
… return len(self.__items)
… def __str__(self):
… return "Stack(%s)" % self.__items
…
>>> s = Stack()
>>> s.is_empty()
True
>>> s.push(1)
>>> s.push(2)
>>> print(s)
Stack([1, 2])
>>> len(s)
2
>>> s.pop()
2
>>> len(s)
1
五道题都写得出来,类的基本功就稳了。
6.4 下一章预告
这一章我们把数据和操作装进了类,代码终于有了结构。可程序跑起来总会遇到意外,文件不存在、网络断了、用户输入了个字符串而你要整数。现在我们的处理方式是 raise ValueError 一把抛出去,然后程序就崩了。
第八章讲异常处理,教程序怎么优雅地应对意外,try、except、finally、raise 怎么用,自定义异常什么时候该写,以及一个我至今还在纠结的问题,到底该捕获还是该让它崩。
网硕互联帮助中心






评论前必须登录!
注册