问题与目标
刚接触面向对象时,我很容易把它理解成一组固定术语:类、对象、封装、继承、多态。定义都能复述,真正写代码时却不知道为什么非要创建一个类。
一个简单需求用函数明明就能完成,如果硬把每一步都放进类里,只会让代码多出 self、构造方法和一层调用。这样的面向对象确实没有说服力。
后来代码里出现了这种组件:它有一批需要长期保存的数据,也有一组始终围绕这些数据展开的操作。例如一个检索器既要保存文档集合,还要负责添加文档、检索文档和限制返回数量。
这时我才真正理解类的价值:
类不是让代码显得高级,而是把相关状态和行为放进一个清楚的边界。
核心理解
如果函数适合表达“做一个动作”,类更适合表达“有一种对象,它保存什么,并且能做什么”。
函数:给我输入,我完成一次处理
对象:我保存自己的状态,并提供围绕这些状态的行为
面向对象不应该成为默认仪式。没有共享状态、逻辑很短时,函数通常更直接;当状态、规则和行为需要一起演化时,类才开始发挥作用。
完成标准
完成这一篇后,应当能够:
- 定义类,并通过类创建多个对象。
- 使用
__init__()初始化实例状态。 - 区分实例属性和类属性。
- 编写实例方法、类方法和静态方法。
- 使用属性接口维护对象的有效状态。
- 理解继承、方法重写和
super()。 - 通过相同接口使用不同实现。
- 根据关系选择继承或组合。
- 判断一个需求更适合函数还是类。
核心概念
类是结构,对象是具体实例
class Document:
def __init__(self, title: str, content: str) -> None:
self.title = title
self.content = content
Document 是类,描述一篇文档应该包含什么。下面创建的是两个不同对象:
python_doc = Document("Python", "Python 适合文本处理。")
rag_doc = Document("RAG", "RAG 会先检索资料,再生成回答。")
每个对象都有自己的 title 和 content。self 表示当前正在操作的那个实例,不是需要手工传入的神秘参数。
实例属性保存状态,方法提供行为
class Counter:
def __init__(self) -> None:
self.value = 0
def increase(self) -> None:
self.value += 1
value 是对象的状态,increase() 是改变状态的行为。如果一个对象的属性可以被任何代码随意改成非法值,类虽然写出来了,边界仍然很松。
封装真正想解决的是:对象自己维护有效状态,外部通过明确方法与它协作。Python 更依赖命名约定和属性接口,而不是把所有东西都锁死。
实例属性通常在 __init__() 中创建:
class ModelConfig:
def __init__(self, model_name: str, temperature: float) -> None:
self.model_name = model_name
self.temperature = temperature
config_a = ModelConfig("model-a", 0.2)
config_b = ModelConfig("model-b", 0.8)
print(config_a.model_name)
print(config_b.model_name)
config_a 和 config_b 保存各自的实例属性。类属性则定义在方法外,由类和实例共享:
class ModelConfig:
supported_provider = "demo"
def __init__(self, model_name: str) -> None:
self.model_name = model_name
print(ModelConfig.supported_provider)
适合放在类属性中的通常是所有实例共同使用的稳定信息。每个对象自己的可变数据应该放在实例属性中。
实例方法、类方法和静态方法
实例方法接收 self,用于读取或修改实例状态:
class Conversation:
def __init__(self) -> None:
self.messages: list[str] = []
def add_message(self, message: str) -> None:
self.messages.append(message)
类方法接收 cls,常用于提供另一种创建对象的方式:
class ModelConfig:
def __init__(self, model_name: str, temperature: float) -> None:
self.model_name = model_name
self.temperature = temperature
@classmethod
def deterministic(cls, model_name: str) -> "ModelConfig":
return cls(model_name=model_name, temperature=0.0)
config = ModelConfig.deterministic("demo-model")
静态方法既不接收 self,也不接收 cls。它与这个类的业务含义相关,但不依赖对象或类的状态:
class ModelConfig:
@staticmethod
def temperature_is_valid(value: float) -> bool:
return 0 <= value <= 2
如果一个静态方法与类的概念关系也很弱,普通模块函数通常更自然。
使用属性接口维护有效状态
前导下划线表达内部属性,property 可以让读取方式保持简单,同时集中验证逻辑:
class ModelConfig:
def __init__(self, temperature: float) -> None:
self.temperature = temperature
@property
def temperature(self) -> float:
return self._temperature
@temperature.setter
def temperature(self, value: float) -> None:
if not 0 <= value <= 2:
raise ValueError("temperature 必须在 0 到 2 之间")
self._temperature = value
构造对象和后续赋值都会经过同一套验证:
config = ModelConfig(0.2)
config.temperature = 0.8
print(config.temperature)
不是每个属性都需要 getter 和 setter。只有确实存在验证、转换或兼容需求时,属性接口才有必要。
私有命名主要表达边界,不等于绝对隐藏
Python 常见的属性命名有三种:
name:公开属性,调用方可以正常使用。_name:约定为内部实现,外部不应依赖。__name:触发名称改写,主要用于减少子类命名冲突。
class ApiClient:
def __init__(self, api_key: str) -> None:
self._api_key = api_key
self.__request_count = 0
def request(self) -> None:
self.__request_count += 1
双下划线不会提供真正的安全隔离,敏感信息也不能因为写成 __api_key 就放心打印或提交到代码库。封装的重点仍然是建立清楚接口,而不是依赖命名技巧隐藏所有数据。
dataclass 适合数据型对象
如果类主要用于保存数据,标准库的 dataclass 可以减少重复代码:
from dataclasses import dataclass
@dataclass(frozen=True)
class Document:
title: str
content: str
frozen=True 表达文档创建后不希望被随意修改。它不是所有场景都需要,但比口头说“请不要改”更明确。
__str__() 和 __repr__() 决定对象怎样展示
特殊方法由 Python 在特定操作中自动调用。__str__() 面向易读展示,__repr__() 更偏向调试和明确表达:
class Document:
def __init__(self, title: str, content: str) -> None:
self.title = title
self.content = content
def __str__(self) -> str:
return self.title
def __repr__(self) -> str:
return (
f"Document(title={self.title!r}, "
f"content={self.content!r})"
)
document = Document("Python", "Python 基础语法")
print(document)
print(repr(document))
dataclass 会自动生成实用的 __repr__()。除了这两个方法,__len__()、__iter__()、__eq__() 等也属于特殊方法;只有对象确实具有相应语义时才应该实现,不能为了展示语法随意添加。
动态属性与 __slots__ 先理解边界
普通 Python 对象通常可以在运行时增加属性:
class Document:
pass
document = Document()
document.title = "Python"
这种动态性很灵活,但也会让拼写错误变成一个全新属性。对象状态应优先在 __init__() 或 dataclass 字段中明确声明。
__slots__ 可以限制实例允许的属性名:
class ModelConfig:
__slots__ = ("model_name", "temperature")
def __init__(self, model_name: str, temperature: float) -> None:
self.model_name = model_name
self.temperature = temperature
它可以减少某些大量对象的属性存储开销,但不是封装或性能优化的默认开关。继承、序列化和部分工具与 __slots__ 结合时还有额外边界,项目没有明确需求时不必主动使用。
组合通常比继承更容易控制
继承表达“子类是一种父类”,组合表达“一个对象使用另一个对象”。
例如问答服务可以使用检索器和模型客户端,这更像组合关系:
QuestionAnswerService
├── Retriever
└── ModelClient
它并不是“一种检索器”,也不是“一种模型”。如果只是为了复用几行代码就建立很深的继承树,后续修改往往会牵一发动全身。
继承、方法重写和 super()
继承适合表达稳定的“是一种”关系。例如本地检索器和远程检索器都可以是一种检索器:
class Retriever:
def search(self, question: str) -> list[str]:
raise NotImplementedError
class LocalRetriever(Retriever):
def __init__(self, documents: list[str]) -> None:
self.documents = documents
def search(self, question: str) -> list[str]:
return [text for text in self.documents if question in text]
子类重新定义父类方法叫方法重写。父类如果有可复用的初始化逻辑,可以通过 super() 调用:
class NamedRetriever:
def __init__(self, name: str) -> None:
self.name = name
class CachedRetriever(NamedRetriever):
def __init__(self, name: str, cache_size: int) -> None:
super().__init__(name)
self.cache_size = cache_size
继承会让子类依赖父类的设计。父类边界还不稳定时,先使用组合通常更容易调整。
多继承和 MRO 只需先理解边界
Python 允许一个类继承多个父类。当多个父类提供同名方法时,Python 按方法解析顺序 MRO 查找:
class JsonMixin:
def format(self) -> str:
return "json"
class TextMixin:
def format(self) -> str:
return "text"
class Exporter(JsonMixin, TextMixin):
pass
print(Exporter().format())
print(Exporter.mro())
Exporter 先继承 JsonMixin,因此先找到它的 format()。多继承中如果多个类都参与初始化,需要遵守可协作的 super() 调用约定,否则容易出现某个父类被重复调用或完全跳过。
第一阶段不设计复杂菱形继承。多继承更适合能力单一、状态很少的 Mixin;带有大量状态和初始化逻辑的组件,组合通常更清楚。
多态关注接口,不只是继承语法
只要不同对象提供相同的调用约定,使用方就可以替换它们。例如本地检索器和向量数据库检索器都提供 search(question),上层问答流程就不必了解内部细节。
这类“面向接口协作”的思路,会比背诵多态定义更接近项目价值。
Python 的多态常表现为“只要提供所需方法即可”:
class LocalRetriever:
def search(self, question: str) -> list[str]:
return [f"本地结果:{question}"]
class VectorRetriever:
def search(self, question: str) -> list[str]:
return [f"向量结果:{question}"]
def answer_with(retriever, question: str) -> None:
results = retriever.search(question)
print(results)
answer_with(LocalRetriever(), "什么是 RAG?")
answer_with(VectorRetriever(), "什么是 RAG?")
为了让类型约定更明确,后续项目可以使用 Protocol 或抽象基类;第一阶段先抓住关键:上层依赖稳定行为,不依赖具体实现细节。
可运行实现:组织一个最小关键词检索器
下面的例子用 Document 表示文档,用 KeywordRetriever 管理文档集合和检索行为。
from dataclasses import dataclass
@dataclass(frozen=True)
class Document:
title: str
content: str
class KeywordRetriever:
def __init__(self, documents: list[Document]) -> None:
self._documents = list(documents)
def add(self, document: Document) -> None:
if not document.content.strip():
raise ValueError("文档内容不能为空")
self._documents.append(document)
def search(self, question: str, limit: int = 2) -> list[Document]:
if limit <= 0:
raise ValueError("limit 必须大于 0")
keywords = set(question.lower().split())
scored_documents = []
for document in self._documents:
content = document.content.lower()
score = sum(keyword in content for keyword in keywords)
if score > 0:
scored_documents.append((score, document))
scored_documents.sort(key=lambda item: item[0], reverse=True)
return [document for _, document in scored_documents[:limit]]
documents = [
Document("虚拟环境", "venv 用于隔离 Python 项目依赖。"),
Document("异常处理", "异常处理让文件读取失败时仍有明确反馈。"),
Document("RAG", "RAG 先检索相关文档,再让模型生成回答。"),
]
retriever = KeywordRetriever(documents)
results = retriever.search("Python 依赖如何隔离")
for result in results:
print(f"{result.title}: {result.content}")
这里有几个值得保留的设计:
Document只表达一篇文档的数据结构。KeywordRetriever保存文档集合,并保证检索逻辑始终围绕这组数据工作。- 构造函数复制了传入列表,避免外部随后修改原列表而意外改变检索器状态。
add()和search()在对象边界内验证输入。- 调用方只关心
search()返回文档,不需要知道内部如何打分。
这个关键词检索器很简单,但以后替换成向量检索时,上层仍然可以围绕“传入问题,返回文档”的接口组织代码。
动手练习:给检索器增加统计状态
在主例子上继续完成三个要求:
- 记录
search()被调用了多少次。 - 提供只读的
document_count属性。 - 提供
from_texts()类方法,用字符串列表快速创建检索器。
from dataclasses import dataclass
@dataclass(frozen=True)
class Document:
title: str
content: str
class KeywordRetriever:
def __init__(self, documents: list[Document]) -> None:
self._documents = list(documents)
self._search_count = 0
@classmethod
def from_texts(cls, texts: list[str]) -> "KeywordRetriever":
documents = [
Document(title=f"文档 {index}", content=text)
for index, text in enumerate(texts, start=1)
]
return cls(documents)
@property
def document_count(self) -> int:
return len(self._documents)
@property
def search_count(self) -> int:
return self._search_count
def search(self, keyword: str) -> list[Document]:
self._search_count += 1
return [
document
for document in self._documents
if keyword.lower() in document.content.lower()
]
retriever = KeywordRetriever.from_texts(
["Python 适合处理文本", "RAG 需要检索资料"]
)
print(retriever.document_count)
print(retriever.search("Python"))
print(retriever.search_count)
这道练习同时使用了实例状态、类方法、只读属性和实例方法。若把 _search_count 改成类属性,再创建两个检索器,就能直接观察共享状态为什么会带来问题。
常见问题与排查
为了面向对象而面向对象
只有一个无状态函数时,包一层类并不会自动提升质量:
class TextHelper:
def clean(self, text: str) -> str:
return text.strip()
如果没有状态、没有可替换策略、也没有一组紧密相关的行为,一个普通函数通常更清楚。
把实例数据写成类属性
类属性由所有实例共享。把可变列表写在类上,会让不同对象相互影响:
class BadRetriever:
documents = []
每个实例自己的文档应该在 __init__() 中创建。
外部可以随意破坏对象状态
如果调用方到处直接修改 retriever._documents,类的验证逻辑就失去了意义。前导下划线表达“这是内部实现”,外部应优先通过公开方法协作。
继承层级太深
为了展示继承而设计 BaseRetriever -> TextRetriever -> LocalTextRetriever -> CachedLocalTextRetriever,很容易让行为分散在多层。没有稳定的“是一种”关系时,组合往往更直接。
一个类承担整个项目
把文件读取、文本切分、检索、模型调用和日志全部塞进 AIManager,只是把巨型脚本换成了巨型类。类仍然需要单一、清楚的主要职责。
小结
这一篇之后,类不再只是函数外面套的一层壳。
当数据需要长期保持有效状态,并且一组行为都围绕它展开时,类能建立清楚边界;当逻辑只是一次简单转换时,函数依然是更好的选择。
知道什么时候不使用类,也是理解面向对象的一部分。
本篇检查清单:
- 能解释类、对象和实例之间的关系。
- 能正确使用
self和__init__()。 - 能区分实例属性与类属性。
- 能判断实例方法、类方法和静态方法的用途。
- 能通过属性接口集中验证状态。
- 能解释单下划线与双下划线命名的边界。
- 能根据展示或调试需要实现
__str__()、__repr__()。 - 能写出一个简单的继承和方法重写案例。
- 知道多继承发生冲突时由 MRO 决定查找顺序。
- 能解释组合为什么经常比深层继承更容易维护。
- 能根据稳定接口替换不同组件实现。
概念和例子跑通之后,下一步不能继续用“好像会了”来判断。还需要通过练习暴露真正的薄弱点,并把错误整理成下一次能复用的检查方法。
License: CC BY-NC 4.0
Updated 4 hours ago
Was this article helpful? Give it a like.
0 comments


