14 - 面向对象编程
面向对象编程(OOP)是编程里的一种"思维方式"。把数据和操作数据的方法打包在一起,形成"对象"。这章讲基础概念,下一章讲继承和多态。
为什么需要面向对象
假设你要写一个游戏,里面有各种角色。每个角色有血量、攻击力这些属性,也有攻击、加血这些行为。
不用面向对象的话,你可能用一堆字典来存:
python
hero1 = {"name": "勇士", "hp": 100, "attack": 15}
hero2 = {"name": "法师", "hp": 80, "attack": 25}
def attack(attacker, target):
target["hp"] -= attacker["attack"]
print(f"{attacker['name']} 攻击了 {target['name']}")
def heal(target, amount):
target["hp"] += amount
这也能用,但数据和操作散落在各处,项目大了就很难管理。
面向对象的方式:
python
class Hero:
def __init__(self, name, hp, attack):
self.name = name
self.hp = hp
self.attack_power = attack
def attack(self, target):
target.hp -= self.attack_power
print(f"{self.name} 攻击了 {target.name},造成 {self.attack_power} 点伤害")
def heal(self, amount):
self.hp += amount
print(f"{self.name} 恢复了 {amount} 点血量,当前血量 {self.hp}")
数据和行为打包在一起,清晰多了。
类和实例
- 类(class):一个模板/蓝图,定义了"这种东西"有什么属性和行为
- 实例(instance):根据模板创建出来的具体对象
python
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
# 创建实例
dog1 = Dog("旺财", "金毛")
dog2 = Dog("小黑", "哈士奇")
Dog 是类(模板),dog1 和 dog2 是实例(具体对象)。每个实例有自己的 name 和 breed。
__init__ 方法
__init__ 是初始化方法,在创建实例时自动调用。它负责设置对象的初始状态。
python
class User:
def __init__(self, name, email):
self.name = name
self.email = email
self.created_at = "2024-01-01"
self.is_active = True
self 是什么?它代表"当前这个实例"。每个方法第一个参数都是 self(这个名字是约定,你也可以写别的,但千万别这么干,所有人都在用 self)。
创建实例时不需要手动传 self,Python 会自动传:
python
user = User("小明", "xm@example.com")
# 等价于 User.__init__(user, "小明", "xm@example.com")
属性和方法
实例属性
python
class Circle:
def __init__(self, radius):
self.radius = radius # 实例属性
c = Circle(5)
print(c.radius) # 5
c.radius = 10 # 可以直接修改
实例方法
python
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def perimeter(self):
return 2 * 3.14159 * self.radius
c = Circle(5)
print(c.area()) # 78.53975
print(c.perimeter()) # 31.4159
类属性
所有实例共享的属性:
python
class Dog:
species = "犬科" # 类属性,所有实例共享
def __init__(self, name):
self.name = name # 实例属性,每个实例不同
dog1 = Dog("旺财")
dog2 = Dog("小黑")
print(dog1.species) # 犬科
print(dog2.species) # 犬科
print(Dog.species) # 犬科(通过类名也能访问)
类属性适合存所有实例都一样的值,比如配置项、常量。
类属性 vs 实例属性
python
class Dog:
tricks = [] # 类属性(所有实例共享!)
def __init__(self, name):
self.name = name
self.toys = [] # 实例属性(每个实例独立)
def add_trick(self, trick):
self.tricks.append(trick)
def add_toy(self, toy):
self.toys.append(toy)
dog1 = Dog("旺财")
dog2 = Dog("小黑")
dog1.add_trick("握手")
dog1.add_toy("球")
print(dog1.tricks) # ['握手']
print(dog2.tricks) # ['握手'] ← 也被影响了!因为共享的
print(dog1.toys) # ['球']
print(dog2.toys) # [] ← 没被影响,因为独立的
可变对象(列表、字典)作为类属性时要特别小心,容易被意外共享。
self 的理解
很多初学者搞不明白 self。其实很简单------它就是"我自己"。
python
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
def get_count(self):
return self.count
当你调用 c.increment() 时,Python 实际做的是 Counter.increment(c)。self 就是 c。
所以你在方法里用 self.count 就是在访问"这个实例自己的 count"。不同的实例有不同的 count,互不影响。
属性的访问控制
Python 没有像 Java 那样的 private、public 关键字。它用命名约定来表示:
公有属性
正常的名字,谁都能访问:
python
class User:
def __init__(self, name):
self.name = name # 公有属性
user = User("小明")
print(user.name) # 随便访问
受保护属性(约定)
一个下划线开头,表示"这是内部用的,别从外面直接动":
python
class User:
def __init__(self, name):
self._age = 25 # 受保护(约定)
user = User("小明")
print(user._age) # 技术上能访问,但不应该
这只是个约定,Python 不会阻止你访问。但好的程序员会遵守这个约定。
私有属性(名称改写)
两个下划线开头,Python 会对名字做"改写"(name mangling):
python
class User:
def __init__(self, name):
self.__password = "secret" # 私有
def check_password(self, pwd):
return pwd == self.__password
user = User("小明")
# print(user.__password) # AttributeError!
print(user.check_password("secret")) # True
# 实际上还是能访问,只是名字被改了
print(user._User__password) # secret(不建议这么用)
两个下划线不是为了"绝对安全",而是为了防止子类意外覆盖父类的属性。别把它当成 Java 的 private。
property 装饰器
如果你想控制属性的读写(比如设置年龄时做校验),用 @property:
python
class User:
def __init__(self, name, age):
self.name = name
self._age = age
@property
def age(self):
"""获取年龄"""
return self._age
@age.setter
def age(self, value):
"""设置年龄(带校验)"""
if value < 0 or value > 150:
raise ValueError("年龄不合理")
self._age = value
user = User("小明", 25)
print(user.age) # 25(调用 getter)
user.age = 30 # 调用 setter
# user.age = -5 # ValueError: 年龄不合理
用起来跟直接访问属性一样(user.age),但背后有方法帮你做校验。这个设计很优雅------以后想加校验逻辑,不用改调用方的代码。
魔术方法(dunder methods)
双下划线开头和结尾的方法叫"魔术方法"或"特殊方法"。它们让你的类可以跟 Python 的内置语法配合:
python
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
# print() 时显示什么
def __repr__(self):
return f"Vector({self.x}, {self.y})"
# str() 时返回什么
def __str__(self):
return f"({self.x}, {self.y})"
# 支持 + 运算
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
# 支持 == 比较
def __eq__(self, other):
return self.x == other.x and self.y == other.y
# 支持 len()
def __len__(self):
return int((self.x**2 + self.y**2) ** 0.5)
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1) # (1, 2)
print(v1 + v2) # (4, 6)
print(v1 == v2) # False
print(repr(v1)) # Vector(1, 2)
常用的魔术方法:
| 方法 | 作用 | 对应语法 |
|---|---|---|
__init__ |
初始化 | Class() |
__str__ |
字符串表示 | str(), print() |
__repr__ |
开发者表示 | repr() |
__len__ |
长度 | len() |
__eq__ |
相等比较 | == |
__lt__ |
小于比较 | < |
__add__ |
加法 | + |
__getitem__ |
索引访问 | obj[key] |
__iter__ |
迭代 | for x in obj |
__contains__ |
包含检查 | x in obj |
不用全部记住,需要的时候查文档就行。
一个完整的例子
python
class TodoList:
"""待办事项列表"""
def __init__(self, name):
self.name = name
self._items = []
def add(self, item: str):
"""添加待办事项"""
self._items.append({"text": item, "done": False})
def complete(self, index: int):
"""标记完成"""
if 0 <= index < len(self._items):
self._items[index]["done"] = True
else:
raise IndexError("无效的索引")
@property
def pending(self):
"""未完成的事项"""
return [item for item in self._items if not item["done"]]
@property
def done(self):
"""已完成的事项"""
return [item for item in self._items if item["done"]]
def __len__(self):
return len(self._items)
def __repr__(self):
return f"TodoList('{self.name}', {len(self)} 项)"
def __str__(self):
lines = [f"📋 {self.name}"]
for i, item in enumerate(self._items):
status = "✅" if item["done"] else "⬜"
lines.append(f" {status} {i}. {item['text']}")
return "\n".join(lines)
# 使用
todos = TodoList("今日任务")
todos.add("学 Python")
todos.add("写代码")
todos.add("摸鱼")
todos.complete(2)
print(todos)
print(f"\n{todos}")
print(f"待完成:{len(todos.pending)} 项")
__slots__:内存优化
默认情况下,Python 对象用 __dict__(一个字典)来存储属性。这很灵活(可以动态添加属性),但每个对象都要维护一个字典,内存开销不小。
如果你需要创建大量对象,可以用 __slots__ 来优化:
python
# 普通类
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
# 使用 __slots__ 的类
class PointSlots:
__slots__ = ["x", "y"]
def __init__(self, x, y):
self.x = x
self.y = y
两个类功能一样,但内存差距很大:
python
import sys
p1 = Point(1, 2)
p2 = PointSlots(1, 2)
print(sys.getsizeof(p1) + sys.getsizeof(p1.__dict__)) # 大约 152 字节
print(sys.getsizeof(p2)) # 大约 56 字节
__slots__ 告诉 Python:"这个类只会有这些属性,别给我分配 __dict__。" 内存能省 40%-50%。
限制
python
class Point:
__slots__ = ["x", "y"]
p = Point()
p.x = 1
p.y = 2
# p.z = 3 # AttributeError! 不能动态添加 __slots__ 之外的属性
什么时候用?
- 大量创建同类型对象时(比如游戏里的粒子、数据处理的记录)
- 追求内存效率时
日常写类不需要加 __slots__,灵活性比那点内存重要。只有当你创建了几十万个对象、发现内存不够用的时候,再考虑。
本章小结
- 类是模板,实例是根据模板创建的对象
__init__初始化实例,self代表当前实例- 类属性被所有实例共享,实例属性各自独立
- 一个下划线是"受保护"约定,两个下划线会做名称改写
@property可以控制属性的读写- 魔术方法让类跟 Python 内置语法配合(
+、len()、print()等) __slots__可以节省内存,但失去了动态添加属性的灵活性
面试题
Q1:__str__ 和 __repr__ 有什么区别?
点击查看答案
__str__:面向用户,返回可读性好的字符串。print()和str()调用。__repr__:面向开发者,返回无歧义的字符串(理想情况下能用来重建对象)。repr()和交互式解释器调用。
python
class Point:
def __str__(self):
return "(3, 4)"
def __repr__(self):
return "Point(3, 4)"
如果只定义了一个,优先定义 __repr__,因为 __str__ 没定义时 Python 会自动用 __repr__ 代替。
Q2:类属性和实例属性有什么区别?什么时候用类属性?
点击查看答案
- 类属性:定义在类中(方法外),所有实例共享同一个值
- 实例属性 :定义在
__init__中,每个实例有自己独立的值
用类属性的场景:
- 所有实例共享的常量(如
PI = 3.14159) - 实例计数器
- 默认配置
注意:可变对象(列表、字典)作为类属性容易被意外共享,应谨慎使用。
Q3:Python 中如何实现私有属性?真的"私有"吗?
点击查看答案
Python 用命名约定实现"伪私有":
_name:约定为受保护,外部不应访问(但不强制)__name:名称改写为_ClassName__name,增加访问难度
但都不是真正的私有------技术上仍然可以访问:
python
obj._Class__name # 可以访问双下划线属性
Python 的理念是"大家都是成年人",靠约定而非强制。如果需要真正的封装(如防止外部修改内部状态),用 @property 提供受控的访问接口。
Q4:@property 装饰器的作用是什么?
点击查看答案
@property 把方法变成"属性"来访问,实现受控的属性读写:
python
class User:
@property
def age(self): # getter
return self._age
@age.setter
def age(self, value): # setter
if value < 0:
raise ValueError
self._age = value
user.age = 25 # 调用 setter
print(user.age) # 调用 getter
好处:
- 语法跟直接访问属性一样简洁
- 可以加校验、计算逻辑、日志等
- 以后从普通属性改为 property 不需要改调用方代码(向后兼容)