1. 为什么我会用Class来实现一个栈
先交代一下背景。前段时间我在帮团队做代码审查,发现不少同事在需要"后进先出"语义的业务场景里,直接用Python的list来模拟。比如处理嵌套括号、实现撤销操作、解析表达式时,到处都是 stack = [],然后 stack.append(x)、x = stack.pop()。代码能跑,没有任何问题,但读起来总觉得少了点什么。
真正让我下决心写这篇内容的契机,是看到有人在一个需要维护多个栈的模块里,复制粘贴了三遍同样的栈逻辑——分别处理整数栈、字符串栈和自定义对象栈。每份代码都是二三十行手写的append/pop检查,改一个逻辑要同步改三个地方。这个场景太典型了:当栈的语义从"临时用一下"变成"项目里反复出现的核心结构"时,list裸用就不再是最优解了。
用Class来实现一个栈,核心价值不是"把代码包起来好看",而是三件事:
-
把状态和方法绑在一起。栈的当前容量、栈顶位置、元素列表这些状态,和push/pop/peek这些操作,天然属于同一个概念实体。Class能把它们封装成一个整体,调用方不需要关心内部怎么存,只需要面向接口操作。
-
把重复逻辑收拢到一处。栈的各种边界检查(空栈能不能pop、满栈能不能push、size怎么统计)只需要写一次,所有使用方共享同一份实现。
-
为将来扩展留出口子。哪天你想给栈加一个"返回最小值"的接口,或者把数组存储换成链表存储,只需要改类的内部实现,外部调用代码一行都不用动。
这也是面向对象设计里最基本的一条原则:找到变化的点,封装它。栈的实现方式(数组还是链表)、边界策略(抛异常还是返回None)、容量限制(固定还是动态),这些都是可能变化的点,用Class恰好能把它们隔离在实现细节里。
当然,如果说得直白点——如果你只是写一道算法题,临时用一个list顶一下完全没问题,没必要上纲上线。但当你发现自己在一个真实项目里第三次复制同样的栈逻辑时,就应该停下来,抽象出一个Class了。这也是我这篇文章想帮大家建立的判断力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先定标准:一个合格栈类的最小接口长什么样
在动手写代码之前,我觉得有必要先聊清楚一个问题:什么样的接口才算"够用"?这决定了我们的Class长什么样,也决定了它好不好用。
栈这个数据结构,语义上就两件事:先进后出。它对外暴露的操作,业界基本已经形成了约定俗成的标准。我从使用者的角度倒推一下,一个栈类至少需要这几个方法:
| 方法名 | 功能 | 时间/空间复杂度 | 典型使用场景 |
|---|---|---|---|
push(item) |
将元素压入栈顶 | O(1) | 入栈、记录现场 |
pop() |
弹出并返回栈顶元素 | O(1) | 出栈、取出最近记录 |
peek()(或top()) |
返回栈顶元素但不弹出 | O(1) | 预览、比较栈顶值 |
is_empty() |
判断栈是否为空 | O(1) | 循环条件检查 |
__len__ |
返回栈内元素个数 | O(1) | 统计、遍历控制 |
这五个操作是底线。往下推,还有一些属于"有了更好"的补充:
__repr__/__str__:打印栈的内容,方便调试。__iter__:让栈可以for循环遍历(注意遍历顺序要符合直觉,通常从栈顶开始)。clear():一次性清空栈。extend(iterable):批量压入多个元素。
我说一下为什么是这五个底线操作而不是别的。push和pop是栈的灵魂,没有它们就不叫栈。peek看起来可以不用,但实际上写表达式求值、括号匹配这类算法时,你经常需要"看一眼栈顶是什么但又不能拿掉它",比如匹配到右括号时,需要看栈顶是不是对应的左括号但不急于弹出。is_empty是循环遍历栈时的天然守卫条件,避免你手动维护一个计数器。__len__则让使用者可以用len(stack)这种直觉方式获取大小,而不是调用一个别扭的size()。
还有一个细节值得注意:Python内置的list其实已经把push和pop的O(1)实现得很好了,所以我们在Class内部存储元素时直接用list是明智的——Append和pop在CPython里都是摊销O(1)的操作,我们不需要重复造轮子去实现一个"高性能数组"。
3. 第一版实现:用list做存储的数组栈
确定了接口,代码就水到渠成了。我直接给出一个完整可用的版本,这个版本我已经在很多项目里实测过,稳定可靠。
python复制class Stack:
"""基于Python列表实现的栈结构,支持泛型元素。"""
def __init__(self, initial_items=None):
self._items = list(initial_items) if initial_items else []
def push(self, item):
"""将元素压入栈顶。"""
self._items.append(item)
def pop(self):
"""弹出并返回栈顶元素;若栈为空,抛出IndexError。"""
if self.is_empty():
raise IndexError("pop from an empty stack")
return self._items.pop()
def peek(self):
"""返回栈顶元素,但不弹出。"""
if self.is_empty():
raise IndexError("peek from an empty stack")
return self._items[-1]
def is_empty(self):
"""判断栈是否为空。"""
return len(self._items) == 0
def __len__(self):
"""返回栈内元素个数。"""
return len(self._items)
def __repr__(self):
"""可视化的表示,方便调试。"""
return f"Stack({self._items!r})"
def __iter__(self):
"""从栈顶到栈底迭代。"""
return iter(reversed(self._items))
来逐段拆解一下每个设计决策背后的考虑。
3.1 为什么用_items而不是直接继承list
第一版里最容易被问到的就是:为什么不直接class Stack(list)?继承list不省事吗,自动就有append和pop了。
我的答案很明确:继承list会让你的栈变成一个"什么都能干"的列表,而栈的核心约束恰恰是"只能从栈顶操作"。如果继承了list,使用方就可以直接stack[0]、stack.insert(2, x)、stack.sort(),这些操作在栈的语义里根本不存在。暴露这些能力就等于给使用者提供了"绕过规则"的口子,很容易写出语义混乱的代码。
我用一个私有属性_items存储数据,对外只暴露我要暴露的方法,这才是封装的意义。单下划线前缀是Python社区的约定,表示"这是内部实现细节,外部不要直接访问"。当然它只是约定,真有人强行访问也拦不住,但这在Python里算是够用的防线了。
3.2 空栈时抛异常还是返回None
这个问题每回讲栈都会引起争论。pop()空栈时,到底该抛IndexError还是返回None?
我个人的倾向是抛异常。原因很简单:返回None会把"取到None值"和"空栈"这两种截然不同的情况混为一谈。比如你的栈里存的是整数,你向栈里压入过None吗?可能没有。但如果某个业务逻辑里栈存的就是可能为None的对象呢?这时候pop()返回None你就分不清是"空了"还是"真取到了一个None"。
抛异常让错误显式化,调用方必须在代码里去处理"栈可能为空"的情况,这反而能逼着程序员写出更健壮的代码。Python内置的list在pop空列表时也是抛IndexError的,我们保持一致,这样大家的记忆负担最小。
如果你确实想要一个"返回None"的版本,可以加一个可选的容错方法,比如safe_pop(),但默认行为始终是抛异常。这种"默认严格、可选宽松"的设计思路,在绝大多数类库设计里都值得借鉴。
3.3 为什么__iter__从栈顶开始
关于迭代顺序,我踩过一个坑。第一版我图省事写成return iter(self._items),结果遍历的时候是从栈底到栈顶的顺序。这在某些场景下会带来微妙的bug:你想依次处理"最近压入的元素",结果第一个处理的却是最早压入的。
后来我统一了这个行为:迭代栈时从栈顶开始,因为这才是"最近优先"的直觉顺序。如果你想在for循环里弹出元素,直接这样写:
python复制while not stack.is_empty():
item = stack.pop()
# 处理item...
如果你只是想遍历不改动栈:
python复制for item in stack:
# item是从栈顶到栈底的顺序
老话说得好,接口设计里一个小小的决策,到了使用方那里可能就是一个bug的根源。迭代顺序这种不起眼的地方,恰恰值得你花30秒想想清楚。
4. 进一阶版本:链式存储的栈,以及为什么你可能用不到
list底层是连续内存,pop和push在末尾操作都是O(1),性能已经非常出色。但在某些场景下,连续内存会成为一个隐患:当元素数量增长到超过当前分配的容量时,list需要整体搬迁到更大的内存区域。虽然CPython做了精心的扩容策略(通常是1.125倍的增量扩容),让均摊成本降到O(1),但在延时敏感的嵌入式环境或者元素极大的场景下,这种突然的"卡顿"可能不可接受。
链式栈用一个个节点来存储元素,每个节点指向下一个节点,根本不需要连续内存。push就是在链表头部插入新节点,pop是删除头节点,时间复杂度同样是O(1),而且单次操作的时间非常稳定,不存在"偶尔扩容导致延迟尖刺"的问题。
直接给出链式栈的实现:
python复制class _Node:
"""链式栈的内部节点。"""
__slots__ = ("value", "prev")
def __init__(self, value, prev=None):
self.value = value
self.prev = prev
class LinkedStack:
"""基于链表实现的栈。"""
def __init__(self):
self._top = None
self._size = 0
def push(self, item):
node = _Node(item, self._top)
self._top = node
self._size += 1
def pop(self):
if self.is_empty():
raise IndexError("pop from an empty stack")
value = self._top.value
self._top = self._top.prev
self._size -= 1
return value
def peek(self):
if self.is_empty():
raise IndexError("peek from an empty stack")
return self._top.value
def is_empty(self):
return self._top is None
def __len__(self):
return self._size
def __repr__(self):
values = []
current = self._top
while current is not None:
values.append(repr(current.value))
current = current.prev
return f"LinkedStack([{', '.join(values)}])"
链式栈的代码比数组栈略多一些,因为你需要自己维护节点的创建和链接关系。这里有几个实现细节值得说:
__slots__的作用:每个节点我们只存两个属性value和prev,用__slots__告诉Python不需要为每个实例维护一个__dict__字典,能节省不少内存。如果你要创建几十万个节点,这个优化相当可观。_top即栈顶:链表头就是栈顶,这样push和pop都只在头部操作,不需要遍历链表。__repr__遍历方式:从栈顶开始沿着prev指针走,把每个元素收集起来,符合我们前面定的"栈顶在前"的显示顺序。
我把两种实现做了个对比,帮助你根据场景选型:
| 维度 | 数组栈(Stack) | 链式栈(LinkedStack) |
|---|---|---|
| 内存分配 | 连续空间,可能触发扩容搬移 | 分散节点,每次push分配一个节点 |
| 单次操作稳定性 | 均摊O(1),但扩容时有尖刺 | 稳定O(1),无尖刺 |
| 空间开销 | 相对紧凑,有预留容量 | 每个节点多一个指针字段 |
| 缓存友好性 | 很好,连续内存利于CPU缓存 | 较差,节点分散 |
| 实现复杂度 | 低 | 中 |
说句实在话,在99%的Python普通业务场景里,数组栈足够了。Python的list经过了深度优化,绝大多数情况下不会有性能问题。链式栈更适合那些"对最坏情况延迟有严格约束"的场景,比如实时数据处理、游戏循环、硬件控制代码。如果你不确定自己是否需要链式栈,那就先别用——等真的遇到性能瓶颈了,再来做针对性的替换。
5. 让栈类变得更好用:追加的那几个实用方法
基础版本的栈能干活了,但用起来总觉得有点"素"。在实际项目中,我几乎总会给栈类加几个实用方法,让代码更顺手。
5.1 支持extend批量入栈
有时候你要把一批元素一次性压栈,比如处理一段文本时,把每个字符都压进去。如果没有批量方法,你要写循环:
python复制for ch in text:
stack.push(ch)
加上extend后:
python复制def extend(self, iterable):
"""将可迭代对象中的元素依次压入栈中。"""
for item in iterable:
self.push(item)
调用方就变成了:
python复制stack.extend(text)
代码更短,意图也更清楚。小小的改进,阅读体验提升一个档次。
5.2 支持clear快速清空
这没什么可说的,就是重置状态:
python复制def clear(self):
"""清空栈中所有元素。"""
self._items.clear()
没有这个方法时,你只能循环pop直到空,或者直接重新stack = Stack()——前者麻烦,后者如果你把这个栈引用传到了好几个地方,重新赋值不会影响其他引用者。有了clear,原地清空,所有引用方都能感知到。
5.3 支持比较运算
两个栈相等的语义是什么?我觉得应该是:类型相同、长度相同、且从栈顶逐个往下比,所有元素都相等。
python复制def __eq__(self, other):
if not isinstance(other, Stack):
return NotImplemented
return self._items == other._items
这里我用了一个小技巧:直接比较内部的_items列表。因为list的相等比较已经是按元素逐个比较了,而且顺序敏感——[3, 2, 1] != [1, 2, 3]。栈的相等恰好也是顺序敏感的(栈顶元素必须对应相等),所以直接复用list的比较逻辑是最省事的做法。
NotImplemented这个返回值很关键。它告诉Python解释器:这个类型不支持和对方比较,请试试对方的反向比较方法。这样当出现stack == some_other_type时,不会抛TypeError,而是合理地返回False或走对方定义的逻辑。
5.4 不需要实现__getitem__
有些朋友会出于"顺手"给栈加一个__getitem__,让栈支持下标访问。我强烈不建议这么做——一旦支持了stack[0],你就破坏了栈的抽象,使用者会想"既然能按下标访问,那我直接遍历或者随机访问是不是也行"。这跟前面说的不继承list是同一个道理。
栈就是栈,它的接口要保持简洁和专注。想按下标访问的人,应该用list,而不是用栈。
6. 用这个栈类解决实际问题:三个经典场景
理论说了一大堆,代码也写了,但栈这东西毕竟是工具,得拿它来干点活才能检验质量。我挑了三个经典的算法场景,正好把这个类的方法全部用一遍。
6.1 括号匹配检查器
判断一段代码里的括号是否合法配对,这是栈的经典入门题。思路很简单:遇到左括号就压栈,遇到右括号就看看栈顶是不是匹配的左括号,是就弹出,不是或者栈空了就是非法。
python复制def is_balanced(s):
pairs = {')': '(', ']': '[', '}': '{'}
stack = Stack()
for ch in s:
if ch in "([{":
stack.push(ch)
elif ch in ")]}":
if stack.is_empty() or stack.peek() != pairs[ch]:
return False
stack.pop()
return stack.is_empty()
这个场景里,peek方法就派上了大用场:我需要"看一眼"栈顶和当前右括号是否配对,但不能直接弹出——如果不配对,这个栈还要留着后续继续检查,不能破坏现场。
6.2 十进制转二进制(或任意进制)
这个例子特别适合展示栈的"后进先出"特性如何在算法中发挥作用。十进制转二进制的经典做法是不断除以2取余数,然后把所有余数逆序排列——而栈天然就是逆序输出的工具。
python复制def decimal_to_base(n, base=2):
digits = "0123456789ABCDEF"
stack = Stack()
if n == 0:
return "0"
while n > 0:
remainder = n % base
stack.push(digits[remainder])
n //= base
result = ""
while not stack.is_empty():
result += str(stack.pop())
return result
除以2得到的第一个余数其实是二进制数的最低位(最右边的位),但它最先入栈,最后出栈,自然就被排到了结果的最后面。这就是"先进后出"和"逆序输出"的完美配合。
6.3 逆波兰表达式求值
逆波兰表达式(后缀表达式)是栈应用的另一个经典场景。比如 "3 4 + 5 *",翻译成人话就是 (3 + 4) * 5。计算规则是:遇到数字就压栈,遇到运算符就弹出两个数,计算结果再压回栈里。
python复制def eval_rpn(tokens):
stack = Stack()
operators = {"+", "-", "*", "/"}
for token in tokens:
if token not in operators:
stack.push(float(token))
else:
b = stack.pop()
a = stack.pop()
if token == "+":
stack.push(a + b)
elif token == "-":
stack.push(a - b)
elif token == "*":
stack.push(a * b)
else:
stack.push(a / b)
return stack.pop()
注意这里有个容易踩的坑:先弹出的是b,后弹出的是a,做减法时必须是a - b而不是b - a。因为栈是后进先出,表达式里a在b之前,所以a先被压栈,弹出时自然在后。这个顺序问题在写任何栈相关代码时都要保持警觉。
这三个例子跑下来,我定义的栈类各个方法都被用到了:push、pop、peek、is_empty、__len__(虽然没直接展示,但判断空栈本质上是用长度判断的)。这也反向验证了当初设计的接口没有冗余,全都是实际会用到的高频操作。
7. 踩坑记录:这几类错误我写栈时都犯过
再好的经验,不如实打实的坑来得深刻。我把自己和身边同事写栈Class时踩过的坑整理一遍,给后来人避避雷。
7.1 默认参数里直接放可变对象
这个坑在Python圈已经是老生常谈了,但每次都能逮到新的受害者:
python复制class Stack:
def __init__(self, items=[]): # 反例!
self._items = items
问题在于:Python的默认参数是在函数定义时求值一次,此后所有调用共享同一个list对象。你创建两个栈,往第一个栈里push一个元素,第二个栈里居然也出现了这个元素——因为它们的_items指向同一个list。
正确写法就是我前面代码里那样:
python复制def __init__(self, initial_items=None):
self._items = list(initial_items) if initial_items else []
用None做默认值,在函数内部再创建新的list,确保每次实例化都有自己的存储空间。
7.2 __len__返回的是None而不是整数
这个坑比较隐蔽。如果你写成:
python复制def __len__(self):
if not self.is_empty():
return len(self._items)
# 忘记写else分支的返回值
那么在栈为空时,__len__会返回None。而Python的内置函数bool(obj)在调用len(obj)时会期望得到一个整数,拿到None就会抛TypeError: 'NoneType' object cannot be interpreted as an integer。
这类问题很难一眼看出来,因为只有空栈时才会触发。我后来养成的习惯是:__len__永远只有一个返回点,并且直接返回数值表达式,不需要任何条件判断。
7.3 pop和peek搞混
这个纯粹是注意力问题,但后果很严重。peek是看一眼不拿走,pop是拿走。在表达式求值这类复杂的逻辑里,很容易在多写了几行代码之后忘记"我到底是已经弹出了,还是只是看了看"。
我给自己定了一个规则:凡是不能确定栈是否会被修改的操作,一律先看peek的文档注释。同时,我在写具体算法的时候,会尽量把"peek后马上pop"这样的操作封装成单独函数,减少大脑维护状态的心智负担。
python复制def _match_and_pop(stack, expected):
"""如果栈顶等于expected则弹出,否则返回False。"""
if stack.is_empty() or stack.peek() != expected:
return False
stack.pop()
return True
7.4 把栈当队列用
最后一个坑比较"哲学"。有些初学者写栈的时候,想到了栈顶操作,但处理pop时就随手用了self._items.pop(0),想从底部弹出——这其实是队列的语义。更隐蔽的是,有些人会为了遍历需要,偶尔用stack[0]访问底部元素,虽然没报错,但已经破坏了栈的抽象。
我的建议是:如果你发现自己经常需要操作栈底,那说明你选错了数据结构,应该换队列(collections.deque)或者普通list。栈的语义就是"我只需要最近的那个元素",如果你需要两头操作,请放弃栈。
8. 进阶思考:泛型栈、线程安全与性能考量
基础版本已经能用了,但如果你要在更严肃的项目里大规模使用栈,还有几个进阶话题值得花时间想一想。
8.1 泛型类型提示
Python 3.5+ 支持类型提示,3.9+ 支持内置泛型语法。给栈类加上类型标注,能让IDE给出更好的补全和静态检查:
python复制from typing import Generic, TypeVar, Iterable, Iterator
T = TypeVar("T")
class Stack(Generic[T]):
def __init__(self, initial_items: Iterable[T] | None = None) -> None:
self._items: list[T] = list(initial_items) if initial_items else []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
if self.is_empty():
raise IndexError("pop from an empty stack")
return self._items.pop()
def peek(self) -> T:
if self.is_empty():
raise IndexError("peek from an empty stack")
return self._items[-1]
def is_empty(self) -> bool:
return len(self._items) == 0
def __len__(self) -> int:
return len(self._items)
Generic[T]表示这个类是一个泛型类。使用的时候,Stack[int]就表示"这个栈只存整数",你往里push字符串时,类型检查器会给出警告。这在多人协作的项目里帮助很大,等于给数据结构加了一层编译期约束。
8.2 线程安全
Python的list操作本身是线程安全的(CPython的GIL保证了单个字节码指令的原子性),但多个操作组合起来不是原子的。比如经典的"检查并弹出":
python复制if not stack.is_empty():
item = stack.pop() # 多线程下这两步之间可能被其他线程插一脚
在多线程环境中,你需要在栈类内部加锁,把"检查+操作"封装成原子方法。最简单的方式是给pop加一个"若空则返回默认值"的原子版本:
python复制def pop_or_default(self, default=None):
"""线程安全地从栈顶取出元素;空栈返回default。"""
with self._lock:
if self.is_empty():
return default
return self._items.pop()
当然,加锁会带来性能开销,所以只在真正需要线程共享同一个栈时才加。单线程环境加锁纯粹是浪费。
8.3 性能基线
我实际测试过,在CPython 3.11上,用list做存储的栈,单次push大约耗时80ns左右,pop也差不多。这个量级意味着:就算你每秒进行几百万次栈操作,也只占一个CPU核的很小一部分。
真正影响性能的往往不是栈本身,而是你存进栈里的对象。如果你的栈存的是大量的大对象,内存和GC会变成瓶颈。这时候考虑用__slots__优化节点、或者改成存储更轻量的数据格式,效果会显著得多。
9. 最后分享一下我的使用心得
写这个栈类前前后后也有几年了,从最早的下意识地"list一把梭",到后来认真设计接口、处理边界、加类型提示,最大的体会是:好的数据结构封装,能让人写出更不容易出错的代码。这个判断不是靠"看起来很面向对象"得来的,而是靠一次次排查bug时发现"原来问题出在我绕过了栈的语义,直接操作了内部list"得来的。
我现在写代码的习惯是:先用最简单的方案(哪怕临时用一个list),一旦发现"栈"这个语义在代码里重复出现两次以上,就立刻抽出Class封装好。这个成本很低,通常不到十分钟,但能在这段代码未来几个月的维护期里持续节省时间。
如果你要接手维护别人的代码,看到类似stack = [[] for _ in range(n)]这种写法,也建议评估一下是否该换成具名Class——不是说都换,而是当逻辑开始复杂(多个栈交互、栈的元素本身要有状态)时,Class的清晰度优势会非常明显。
我建议你从今天起,在自己的项目里试着重写一个栈类,先用数组版,再用链表版,把两种实现的差异体会一遍,然后在未来的代码里用起来。不需要什么复杂框架,一个纯Python文件就能完成,但这个过程对你的数据结构和面向对象设计能力都会有实打实的提升。
