Python 设计模式——空对象模式(Null Object Pattern)详解
本文面向零基础新手,从“用 None 的麻烦”到“用空对象代替 null”,从日志、依赖到树节点,全方位讲解空对象模式。
一、什么是空对象模式?
1.1 通俗理解
空对象模式是一种行为型设计模式,核心思想是:
当某个依赖可能“没有”时,不要用 None(或 null)表示“没有”,而是提供一个和正常对象实现同一接口的“空对象”;这个空对象的方法要么什么都不做,要么返回安全的默认值。这样调用方不需要到处写 if x is not None,直接调接口即可,代码更简洁、更安全。
可以这样类比:
- 不用空对象:你有一个“日志器”,有时没配置日志器就传 None。每次打日志都要写
if logger is not None: logger.log(...),否则可能报错。代码里到处都是“判空”。 - 用空对象:提供一个“空日志器”(NullLogger),和真实日志器实现同一接口,但 log 方法什么都不做。没配置时传 NullLogger 而不是 None,打日志时直接
logger.log(...),不需要 if。“没有”也是一种对象,只是行为是“无操作”。
所以,空对象模式解决的是:用“空对象”代替 null/None,让调用方不必反复判空,同时保持接口一致、避免 None 导致的 AttributeError。
1.2 为什么需要空对象模式?
场景一:避免到处判空
若依赖可能是 None,每个使用处都要写 if obj is not None: obj.do_something(),代码重复且容易漏写。用空对象后,obj 永远非 None,直接 obj.do_something(),空对象内部什么都不做即可。
场景二:接口一致、多态
调用方只依赖“接口”(如 Logger),不关心是真实实现还是“空实现”;真实和空都实现同一接口,可以多态替换。测试或未配置时注入空对象,业务代码不用改。
场景三:安全默认值
例如“没有父节点”时返回 NullNode 而不是 None,调用 parent.get_name() 不会报错,NullNode.get_name() 返回空字符串或固定值;遍历树时不用每一层都判空。
1.3 适用场景(什么时候用?)
| 场景 | 说明 |
|---|---|
| 依赖可选、经常要判空 | 如可选日志、可选回调、可选策略,希望调用方不写 if obj。 |
| 需要“无操作”或安全默认 | 没有真实对象时,提供“什么都不做”或“返回默认值”的替代品。 |
| 接口统一、便于测试 | 测试时注入空对象代替真实依赖,业务代码不分支。 |
简单记忆:可能没有的对象用同接口的“空实现”代替 None,省判空、保安全、接口一致。
二、空对象模式的结构(三个角色)
| 角色 | 说明 |
|---|---|
| 抽象类型(接口) | 定义客户依赖的方法(如 log、handle、get_name);真实对象和空对象都实现该接口。 |
| 真实对象(Real Object) | 正常实现,有实际行为。 |
| 空对象(Null Object) | 实现同一接口,但方法体为空操作(什么都不做)或返回安全默认值(如空字符串、0、空列表);不抛异常、不依赖外部资源。 |
关系可以理解为:
- 客户只依赖接口,拿到的可能是真实对象,也可能是空对象;客户不判空,统一调用接口。
- 空对象和真实对象可替换:没有真实对象时,把空对象注入进去即可。
三、示例一:日志器(真实 vs 空)
需求:业务代码依赖“日志器”接口写日志;有时未配置日志器,希望不报错、也不到处写 if logger。提供真实 Logger 和 NullLogger,未配置时用 NullLogger。
3.1 接口 + 真实实现 + 空对象
from abc import ABC, abstractmethod
class Logger(ABC):
@abstractmethod
def log(self, message: str):
pass
class FileLogger(Logger):
def __init__(self, path: str):
self.path = path
def log(self, message: str):
print(f" [写入文件 {self.path}] {message}")
class NullLogger(Logger):
"""空对象:实现同一接口,但什么都不做"""
def log(self, message: str):
pass # 无操作
3.2 使用:不判空
def do_work(logger: Logger):
logger.log("开始处理") # 不写 if logger
# ... 业务逻辑 ...
logger.log("处理完成")
# 有日志器时
do_work(FileLogger("app.log"))
# 未配置日志器时,注入空对象
do_work(NullLogger())
要点:do_work 不关心 logger 是 FileLogger 还是 NullLogger,也不写 if logger is not None;NullLogger 的 log 是空操作,不会报错。
四、示例二:可选依赖(服务 / 回调)
需求:某个服务可能未注入(如邮件服务、支付回调);调用方希望统一写 service.send(...) 或 callback.on_success(),未配置时“什么都不做”。用空对象代替 None。
class EmailService(ABC):
@abstractmethod
def send(self, to: str, subject: str, body: str):
pass
class RealEmailService(EmailService):
def send(self, to: str, subject: str, body: str):
print(f" [发送邮件] 给 {to}: {subject}")
class NullEmailService(EmailService):
def send(self, to: str, subject: str, body: str):
pass
# 依赖注入时:没配置就注 NullEmailService
email_service = NullEmailService() # 或 RealEmailService()
email_service.send("user@example.com", "标题", "内容") # 从不判空
五、示例三:树节点(没有子节点时用空节点)
需求:树节点有左子、右子;没有子节点时若用 None,每次访问都要 if node is not None。改用“空节点”(NullNode),和真实节点同一接口,get_value 返回默认值、get_left/get_right 返回自己或另一个空节点,这样遍历时不用判空。
class Node(ABC):
@abstractmethod
def get_value(self):
pass
@abstractmethod
def get_left(self) -> "Node":
pass
@abstractmethod
def get_right(self) -> "Node":
pass
class RealNode(Node):
def __init__(self, value, left: Node = None, right: Node = None):
self._value = value
self._left = left or NullNode()
self._right = right or NullNode()
def get_value(self):
return self._value
def get_left(self) -> Node:
return self._left
def get_right(self) -> Node:
return self._right
class NullNode(Node):
"""空节点:没有真实数据,子节点也是空"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def get_value(self):
return None
def get_left(self) -> Node:
return self
def get_right(self) -> Node:
return self
# 遍历时不用判空
def sum_tree(node: Node) -> int:
if node.get_value() is None: # 空节点
return 0
return node.get_value() + sum_tree(node.get_left()) + sum_tree(node.get_right())
root = RealNode(1, RealNode(2), RealNode(3))
print(sum_tree(root)) # 6
要点:RealNode 用 NullNode() 代替 None 表示“没有子节点”;NullNode 的 get_left/get_right 返回自己,sum_tree 遇到“值为 None”就返回 0,无需写 if node is None。
六、示例四:返回空集合 / 空迭代(常见变体)
有时“没有结果”不返回 None,而返回空列表、空字典,调用方可以统一 for 或 .get,不用判空。这也是空对象思想的一种体现。
def get_user_roles(user_id: int):
# 若没有角色,返回空列表而不是 None
return [] # 或 roles_list,调用方: for r in get_user_roles(uid) 不用 if result
七、空对象模式 vs 判空 / Optional
| 方式 | 做法 | 缺点 / 优点 |
|---|---|---|
| 直接用 None | 没有就传 None,调用处 if x is not None | 容易漏判、代码重复。 |
| Optional + 判空 | 类型标注 Optional[T],调用前检查 | 类型更清晰,但仍要写判空。 |
| 空对象 | 没有就传同接口的“空实现” | 调用方不判空、接口一致;多一个空对象类。 |
简单记忆:空对象 = 用“空实现”代替 null,同一接口、调用方不判空。
八、常见问题与注意点
8.1 空对象要不要单例?
若空对象无状态、到处可共用,可以做成单例(如 NullNode、NullLogger),省内存。若有状态或希望每次 new 也可以,看需求。
8.2 什么时候不该用空对象?
若“没有”必须被明确区分(如“用户未登录”和“用户已登录但无权限”),用 None 或 Optional 更合适,调用方必须分支处理。空对象适合“没有时就当什么都不做/用默认值”的场景。
8.3 空对象方法要不要返回 None?
看接口约定。若接口返回 str,空对象可返回 “”;若返回 list,可返回 [];若返回 None 也是合理默认,就返回 None。原则是不抛异常、调用方能安全使用返回值。
九、小结
- 空对象模式:用与真实对象同一接口的空对象代替 null/None;空对象方法无操作或返回安全默认值,使调用方不必判空、代码更简洁、避免 None 导致的错误。
- 三个角色:抽象类型(接口)、真实对象、空对象(空实现或默认值)。
- 典型用途:可选日志、可选服务/回调、树/图的空节点、可选策略等。
- 实现要点:空对象实现完整接口;方法体为空或返回默认值;调用方只依赖接口,不写 if obj is not None。
建议先写“Logger + NullLogger”,再试“可选服务”或“树空节点”,体会“不判空、接口一致”,这样对空对象模式会掌握得比较扎实。