📚 Tkinter 界面跟学日记系列
01第一扇窗口:30 分钟做出你的第一个 Python 界面入门
02让界面活起来:按钮点击背后的完整故事基础
03布局三板斧:side、expand、fill 和 Frame 一次讲透进阶
04面向对象毕业作:用 class 写一个正经待办清单(当前篇)进阶
读完本篇你将能:把零散的函数版待办清单重构成正规 class 应用,彻底搞懂 self 的两条查找路径,并独立写出可添加、删除、计数的完整 Tkinter 程序。
前三篇我们一路打怪升级:第一扇窗口、按钮事件、布局三板斧。你也成功写出了能添加、删除、计数的函数版待办清单。但回头看那段函数代码,是不是总觉得哪里别扭?
别扭点在于一个"顺序尴尬":我们在函数里写 entry.insert(...),可 entry 这个变量是在函数外面才被创建的。函数定义的那一刻,组件还根本不存在。
那为什么没报错?因为函数体里的名字不是定义时找,而是调用那一刻才找。等你点按钮时,外面的 entry 早就造好了,所以能找到。但这就埋下了坑:变量散落在外层,函数之间靠"全局变量"互相够,代码一长就乱。
正规军的解法,是把"窗口 + 所有组件 + 所有操作"打包成一个类(class)。今天我们就用 class 重写一遍待办清单,作为整个系列的毕业作。
一、class 三概念:图纸、出厂设置、我自己
你已经会了窗口、事件、布局。现在只是换一种"打包方式"。记住三个比喻就够入门:
类类 = 图纸。class TodoApp: 只是画了一张"待办清单长什么样"的图纸,它不会立刻造出任何东西。
初__init__ = 出厂设置。当你写 app = TodoApp(win) 造出实例的那一瞬间,__init__ 会被 Python 自动执行,负责把组件一件件装好。
我self = "我自己"。self.entry 意思是"这个应用自己的输入框家当"。凡是这个应用要反复用到的组件,都写成 self.xxx 存起来。
先看一个最小骨架,感受三个概念怎么落地:
Python
import tkinter as tk
class TodoApp:
def __init__(self, master):
self.master = master # 把窗口存为"我家当"
self.lbl = tk.Label(master, text="待办清单")
self.lbl.pack()
win = tk.Tk()
app = TodoApp(win) # 这一行触发 __init__
win.mainloop()
运行后窗口出现一行"待办清单"。关键在最后两行:TodoApp(win) 执行时,Python 自动把 win 当作第一个实参塞进 __init__ 的 master 形参,于是 self.master = master 就记住了窗口。
💡 小贴士
方法的第一个参数永远是 self,但调用时你不用写。写 app.add_item() 时,Python 自动把 app 填进 self。这是规定,不是可选项。
二、self 判定三规则:到底什么时候加 self.
很多人把 self. 当成"可省略的修饰前缀",想写就写、忘了也行——这是 90% 报错的根源。先记死三条规则:
1其他方法里还要用到的组件 → 写成 self.entry 存起来。
2只在 __init__ 里用一次、别处不碰的临时变量 → 不写 self,直接 master 即可。
3在方法内部调用自己的其他方法 → 必须 self. 开头,例如 self.update_count()。
根因:Python 找名字有两条完全不同的路
为什么不能省略 self?因为 self.entry 和裸名 entry 是两条不同的查找路径,Python 不会自动帮你翻 self 的家当:
路径 A:裸名 entry
沿 局部 → 全局 → 内建 一层层找
哪层都没有 → NameError
路径 B:self.entry
直接去"这个对象的家当"里找
找不到 → AttributeError
所以,你写在 __init__ 里的 self.entry,在别的方法里用裸名 entry 是绝对找不到的——它不在局部、不在全局、不在内建,而在另一个对象的家当里。来看一个会报错的真实写法:
Python · 错误写法
def add_item(self):
text = entry.get() # 裸名 entry,找不到!
一旦点按钮触发 add_item,Python 沿路径 A 找 entry:局部没有、全局没有、内建也没有,直接崩:
真实报错
Traceback (most recent call last):
File "todo.py", line 18, in add_item
text = entry.get()
NameError: name 'entry' is not defined
正确写法就是老老实实写 self.entry.get()。记住一句话:self. 不是前缀,是另一条路的入口。
三、master 过户:把窗口"过户"给应用
上一段你看到 self.master = master 这行总在 __init__ 里。它干的事是:把从外面传进来的窗口,正式"过户"成这个应用的家当。为什么要过户?看清楚两者的寿命:
Python
def __init__(self, master):
self.master = master # 过户:窗口变成 app 的家当
self.entry = tk.Entry(master) # 用 master 当父容器
·master 是参数,本质是个局部临时工。__init__ 一执行完,这个局部变量就跟着函数栈一起消失了。
·但 self.master = master 把窗口的引用存进了对象家当,对象不死,它就在。之后任何方法都能 self.master.title("待办") 操作窗口。
·方法本身不会消失,消失的是它的局部参数。类定义好后,方法像"说明书"一直挂在类上,只是每次调用时的局部变量是临时的。
💡 小贴士
如果只在 __init__ 里用窗口、别处再也用不到,其实可以不写 self.master。但待办清单里我们后面要改窗口标题、弹提示,所以保留 self.master 更稳妥。
四、Listbox 毕业用法:清单的容器
前三篇你认识的 Button、Entry、Label 都只能"单打独斗"。待办清单真正缺的是能盛一排任务、每条还能单独选中删除的列表框 Listbox。把它想成"带编号的抽屉柜":加任务就是往末尾塞一张纸条,删任务就是抽掉某个抽屉。
毕业级待办清单,这五个方法必须刻进肌肉记忆:
1lb.insert("end", text) — 把任务加到最末一行。"end" 是 Listbox 专用关键字,代表最后。
2lb.delete(idx) — 删掉下标为 idx 的那一行。lb.delete(0, "end") 可一次清空全部(从 0 到末尾)。
3lb.get(idx) — 取回某行的文字内容,通常配合删除前确认。
4lb.size() — 返回当前总行数,用来显示"共 N 条"。
5lb.curselection() — 返回当前选中行的下标元组,这是新手最容易栽的坑,下面单独讲。
还有两个进阶货:lb.itemconfig(idx, fg="gray") 能单独改某一行的颜色(比如把"已完成"标记成灰色);<<ListboxSelect>> 是 Listbox 专属事件,用户点选某一行时触发。
curselection 返回的是元组,不是数字
很多新手写 idx = lb.curselection() 然后直接 lb.delete(idx) —— 这就错了。curselection() 永远返回元组:没选中是空元组 (),选了第 2 条是 (2,)。要拿真正的下标,得取元组的第 0 个元素 sel[0]:
Python · curselection 取值套路
sel = self.lb.curselection() # 返回元组:选中第2条是 (2,),没选是 ()
if len(sel) == 0:
return # 没选中任何条,直接回家
idx = sel[0] # 取元组里第一个(也是唯一)元素
self.lb.delete(idx)
💡 小贴士
为什么用 len(sel) == 0 而不是 sel == ()?因为 Listbox 支持多选(multiple 模式),理论上可能返回 (1, 3) 这种多个元素。用 len 判断最稳妥,无论单选多选都不会漏掉守卫。
command 回调 vs bind 回调:参数差一个
给组件绑行为有两条路,参数签名不一样,写错必崩:
Python
btn = tk.Button(frame, text="添加", command=self.add_item)
self.lb.bind("<<ListboxSelect>>", self.on_select)
·Button 的 command= 是"函数引用"写法:只传函数名,不加括号。触发时 Tkinter 自己调用 add_item(self) —— 只有 self 一个参数。
·Listbox 的 bind(事件, 函数) 是"事件绑定"写法:触发时 Tkinter 会多塞一个 event 参数,调用 on_select(self, event)。方法定义必须写 def on_select(self, event):,否则少一个参数直接报错。
五、完整毕业项目:正规版待办清单
把前面所有零件拼起来:顶部一个输入框 + 添加按钮,中部一个 Listbox 撑满,底部一行放计数标签和两个按钮。所有组件都存成 self.xxx,所有操作都写成方法。下面是完整、可直接运行的毕业作,复制保存为 todo.py 跑起来就行(底部按钮区我多留了一个位置,练习区会用到):
Python · todo.py 完整毕业项目
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
|
import tkinter as tk
class TodoApp:
"""一个用 class 组织的正规待办清单应用。"""
def __init__(self, master):
self.master = master
self.master.title("我的待办清单")
# 顶部输入区
top = tk.Frame(master)
top.pack(fill="x", padx=10, pady=10)
self.entry = tk.Entry(top)
self.entry.pack(side="left", fill="x", expand=True)
btn_add = tk.Button(top, text="添加", command=self.add_item)
btn_add.pack(side="left", padx=5)
# 中部列表
self.lb = tk.Listbox(master)
self.lb.pack(fill="both", expand=True, padx=10, pady=5)
self.lb.bind("<<ListboxSelect>>", self.on_select)
# 底部按钮 + 计数
bottom = tk.Frame(master)
bottom.pack(fill="x", padx=10, pady=10)
self.count_label = tk.Label(bottom, text="共 0 条")
self.count_label.pack(side="left")
self.btn_del = tk.Button(bottom, text="删除选中", command=self.del_item)
self.btn_del.pack(side="right", padx=5)
self.btn_reset = tk.Button(bottom, text="重置", command=self.reset_all)
self.btn_reset.pack(side="right")
def add_item(self):
text = self.entry.get().strip()
if not text:
return
self.lb.insert("end", text)
self.entry.delete(0, "end")
self.update_count()
def del_item(self):
sel = self.lb.curselection()
if len(sel) == 0:
return
self.lb.delete(sel[0])
self.update_count()
def reset_all(self):
self.lb.delete(0, "end")
self.update_count()
def update_count(self):
n = self.lb.size()
self.count_label.config(text="共 %d 条" % n)
def on_select(self, event):
sel = self.lb.curselection()
if sel:
print("选中第", sel[0], "条")
if __name__ == "__main__":
win = tk.Tk()
app = TodoApp(win)
win.mainloop()
|
对照上面看几个关键点:类名 TodoApp 用大驼峰,属性全用小蛇形(self.entry、self.lb、self.count_label);顶部用独立的 Frame 装输入区,中部 self.lb.pack(fill="both", expand=True) 让列表撑满剩余空间;add_item 里 if not text: 挡住空输入,每一处增删后都 update_count() 刷新计数。最后 if __name__ == "__main__": 是 Python 标准入口,保证这段"造窗口"的代码只在直接运行文件时才执行。
六、真实 review 踩坑:四个一定会犯的错
我把初版代码交出去做 review,被挑出四个真实 bug。每一个都来自"想当然",几乎每个初学者都会踩。趁现在记牢,能省你两小时调试。
⚠️ 常见错误 1:空输入判断用 get() == False
if self.entry.get() == False: return —— get() 永远返回字符串,字符串永远不等于布尔值 False,所以这行判断恒为 False,空输入照样被加进清单。
✓ 正确:用"假值"概念判断 —— 空字符串 "" 本身就是假,所以 if not text: 就够了。顺手 .strip() 去掉首尾空格,纯空格也不算任务。
⚠️ 常见错误 2:没选中就点删除
self.lb.delete(self.lb.curselection()) —— curselection() 返回的是元组,但 delete() 要的是整数下标。没选中时返回空元组 (),整行直接崩。
✓ 正确:先取元组,再用
len(sel) == 0 守卫,空就
return 回家:
Python · 守卫写法
sel = self.lb.curselection()
if len(sel) == 0:
return
self.lb.delete(sel[0])
⚠️ 常见错误 3:用 Label 当容器
box = tk.Label(master); tk.Button(box, ...) —— Label 的本职是"显示一行文字",它装不下子组件。把按钮塞进 Label,要么报错要么显示错乱,这是语义错位:你想装东西,却选了"只会显示文字"的零件。
✓ 正确:要装多个子组件,用 tk.Frame(master)。Frame 是专门当"容器/分区"的空白盒子,顶部输入区、底部按钮区都该是 Frame,再把 Entry、Button 放进 Frame 里。
⚠️ 常见错误 4:名字撒谎的命名
初稿里出现过 self.btnB2 = tk.Label(...) —— 变量叫 btnB2,实际却是 Label。三个月后你看到 self.btnB2 会以为它是按钮去调 .invoke(),名字在骗你。
✓ 正确:遵守三条命名约定 —— 类名大驼峰 TodoApp;属性小蛇形 self.count_label;名字要反映真身,是 Label 就叫 self.lbl_xxx,是按钮才叫 self.btn_xxx。好名字是不用看定义就知道它是什么。
七、系列成长回顾:四篇路线图
从第一篇到这一篇,你其实走完了一条完整的"界面编程入门路线图"。每一篇都不是孤立知识点,而是给下一篇铺路:
01 窗口组件
Tk / Label / Button
→
02 事件驱动
command / 回调
→
03 布局
side / expand / Frame
→
04 面向对象
class / self(本篇)
01窗口组件:先有窗口、有能看的零件,程序才"立"得起来。
02事件驱动:让按钮"点了才有反应",程序从死板的流程变成活的。
03布局:用 side / expand / Frame 把零件排整齐,界面才像样。
04面向对象(本篇):把窗口+组件+操作打包成 class,从"能跑"升级到"正规、好维护"。
八、小结
本篇我们把零散的函数版待办清单重构成了正规 class 应用。你掌握了三件事:一是 class / __init__ / self 的"图纸—出厂设置—我自己"模型;二是 self. 是独立于裸名的另一条查找路径,不能省略;三是 Listbox 的五个方法和 curselection() 返回元组的坑。能做到添加、删除、计数、重置,你已经写出了第一个"像样"的桌面小程序。
📖 知识回顾
class 与 __init__
self 两条查找路径
master 过户
Listbox 五方法
curselection 元组
command vs bind
假值判断 not text
命名规范
✏️ 动手练习
🟢 基础验证:加一个"清空全部"按钮
在毕业项目底部按钮区,新增一个"清空全部"按钮,点一下就把清单里所有任务一次性删光。提示:你其实已经有了能清空的 reset_all 方法,问题在于"怎么让按钮碰到它"。
参考解法(在 __init__ 底部按钮区加两行):
Python
self.btn_clear = tk.Button(bottom, text="清空全部", command=self.reset_all)
self.btn_clear.pack(side="right", padx=5)
🟡 组合应用:点选时显示选中序号
用户点 Listbox 某一行时,把计数标签改成"选中第 N 条"(N 从 1 开始)。用 <<ListboxSelect>> 事件触发,记得本篇讲过的:bind 回调必须写 event 参数。
参考解法(改写 on_select):
Python
def on_select(self, event): # bind 回调必须带 event 参数
sel = self.lb.curselection()
if not sel:
return
idx = sel[0]
self.count_label.config(text="选中第 %d 条" % (idx + 1))
🔴 开放挑战:双击把任务标记"已完成"
给 Listbox 绑一个 "<Double-Button-1>"(鼠标左键双击)事件;在回调里用 event.y 配合 lb.nearest(event.y) 找到被双击的是第几行,再用 lb.itemconfig(idx, fg="gray") 把它变成灰色,表示已完成。想想:怎么让双击在"已完成/未完成"之间来回切换?(提示:记录一个已完成下标的集合)
🎓 系列总结:四篇走完界面入门路线图
从第一扇窗口,到按钮事件,到布局三板斧,再到本篇用 class 把待办清单重构成正规应用——你已经在自己的电脑上写出能添加、删除、计数、重置的桌面小程序。这四条路线(窗口组件 → 事件驱动 → 布局 → 面向对象)是任何 GUI 编程的通用骨架,换到别的语言也成立。
如果这篇对你有用,欢迎关注公众号,把系列收藏起来,方便随时回看;也欢迎点赞、在看、分享给同样在学 Python 的朋友。你们的反馈是我继续写下去的动力。
系列虽到此告一段落,但待办清单还能继续长大:比如把任务存进文件(关掉程序下次还能看到)、双击编辑任务、甚至将来走向Web 前端路线(用浏览器当界面、数据放后端)。这些方向我们以后单独开坑,敬请期待。