1. 我为什么坚持用海龟作图讲 Python 基础语法
1.1 一段看得见的代码,比十页语法说明管用
教 Python 基础语法最尴尬的地方在于:前几节课讲变量、循环、函数,学生敲完代码,终端里只吐出一行Hello World或者一堆数字,屏幕黑漆漆一片。你会明显感觉到,某些人的注意力从第二十分钟开始就往下掉——不是他们不聪明,而是代码和结果之间缺少可视化的反馈回路。海龟作图(turtle模块)刚好把这个回路补上了:你写一行forward(100),屏幕上就有一条线往前跑 100 像素;你把循环次数从 3 改成 5,图形立刻从三角形变成五边形。这种即时、可见、可玩的反馈,是任何语法讲解都替代不了的。
turtle是 Python 标准库自带的绘图模块,不需要pip install,装了 Python 就能用。它的定位是基础扩展模块——不是第三方生态里那些复杂的图形库,而是做基础语法训练时,能把"顺序、分支、循环、函数、递归、列表"这些抽象概念变成具体图形的工具。标题里写的是"基础语法(二)",意思很明确:学习者在上一阶段已经能写变量、算术、if和简单的for,现在要找到一个载体,把这些语法真正"用起来"。
这篇文章面向三类人:一是正在带入门课、想找一套能直接抄作业的课堂素材的人;二是自学到循环和函数,但一写完整程序就卡壳、不知道拿什么练手的人;三是以前用过turtle,但只停留在画了几个正方形,想把它真正跑通、顺便补上坐标系、事件、动画这些坑的人。下面我会从整体设计思路开始,一路讲到具体参数怎么算、代码怎么写、以及那些只有实际跑过才会遇到的坑。
1.2 海龟作图到底在训练哪几种能力
很多人对turtle的印象停留在"小学生画图工具",这其实误判了它的训练价值。它至少同时压住了四种能力。
第一是空间建模能力。画一个正 n 边形,你需要知道外角是 360/n 度,还需要理解"转角"到底是转内角还是外角。这个换算过程,本质上是在把几何关系翻译成代码里的数值,是典型的建模训练。
第二是过程分解能力。一个复杂图形从来不是一口气写出来的,通常要先分解成若干子图形,每个子图形封成一个函数,再用主函数调度。这就是最简单的模块化思想。
第三是状态管理意识。海龟有位置、朝向、笔的状态(抬起还是落下)、画笔颜色、填充颜色、线宽、速度,这一堆东西共同构成一个"状态机"。你稍不留神忘了penup(),一条莫名其妙的斜线就会横穿整个画面。这种"状态没管好就出事"的经验,对以后理解更复杂的程序状态非常有帮助。
第四是调试直觉。图形画歪了,你要判断是角度算错、还是转向方向反了、还是坐标系理解偏了。这种定位问题的能力,比记住语法重要得多。
1.3 为什么不选别的绘图方案
带入门课的时候,能用来做可视化反馈的工具不少,我挨个试过,最后回到turtle是有原因的。
matplotlib当然更强,但它对新手不友好——导入、figure、axes、show 这一套流程本身就是负担,而且它的强项是画数据图,不是"用代码画一条线"这种最朴素的动作。让刚学完for循环的人上手matplotlib,他会被 API 淹没,反而学不到循环本身。
pygame反馈更酷,但事件循环、Surface、坐标系、帧率这些概念,对刚学完if的人来说是超纲的。而且pygame装起来有小概率踩坑,课堂上装环境失败是最打击积极性的场景。
网页端的 Canvas 也不错,但要引入 HTML/JS,跨度太大。
turtle的平衡点刚好:零安装、零配置、API 极简、结果立刻可见,而且它的抽象层级足够低,一行代码就是一个动作,正好对应刚学的一行语句。
| 方案 | 上手成本 | 可视化反馈 | 与基础语法的贴合度 | 适合阶段 |
|---|---|---|---|---|
turtle | 极低 | 强,实时 | 极高,一行一动作 | 语法入门到函数递归 |
matplotlib | 中等 | 强,但偏静态图表 | 中等 | 数据处理阶段 |
pygame | 较高 | 很强,可做游戏 | 偏低 | 面向对象之后 |
| 网页 Canvas | 中等偏高 | 强 | 偏低 | 前后端结合阶段 |
注意:选工具的标准不是"哪个更强",而是"哪个能让学习者的注意力集中在当前要练的语法点上"。工具一旦抢戏,学习效率就掉下来了。
2. 环境准备与最小可运行骨架
2.1 三种运行方式,先选对不踩坑
turtle的代码在三种环境里跑,表现差别不小,这点很多人一开始没意识到。
第一种:命令行直接跑脚本。写完xxx.py,在终端里python xxx.py,会弹出一个独立窗口。这是最稳的方式,窗口关掉程序才结束,行为和预期一致。我一般建议新手先用这个方式跑通。
第二种:IDLE 里跑。装了 Python 就自带 IDLE,它的编辑器里写turtle代码,运行后同样弹窗。优点是零配置,缺点是编辑器太弱,代码一长就难受。
第三种:编辑器或 notebook 环境。在 PyCharm、VS Code 这类编辑器里跑,或者在 Jupyter 类环境里跑,就需要额外注意。Jupyter 里跑turtle容易出问题,因为它的内核和窗口的事件循环会互相打架,表现是窗口卡住不动,或者画完就消失。PyCharm 有个历史设置项,把绘图输出重定向到科学视图里,也会导致窗口异常。
我的建议很直接:入门阶段就用命令行或 IDLE 跑.py文件,别在交互式环境里折腾。等把图形画明白了,再去研究怎么在 notebook 里跑,那时候你已经有能力判断问题出在哪了。
2.2 画布、海龟、事件循环,三件套一次说清
turtle的世界里只有三个核心角色,理解了它们的关系,后面所有问题都好排查。
画布(Screen)是一块窗口区域,有宽高、有背景色、有坐标系。它由turtle.Screen()或者隐式创建,是绘图的"纸"。
海龟(Turtle)是拿着笔的那个对象,有位置、有朝向。它默认在画布正中央、朝向右侧(东)。你所有的forward、right、goto,操作的都是这只海龟。
事件循环(mainloop)是让窗口保持存活的机制。如果程序执行完最后一行就退出,窗口会跟着一起消失——这就是很多人遇到的"一闪而过"。解决方式是在脚本末尾调用turtle.done(),它会阻塞住程序,等用户关闭窗口才结束。
这三者的关系,用一句话概括:画布提供舞台,海龟在舞台上画画,事件循环负责让舞台别塌了。缺了第三样,前两样就白干。
2.3 一份可以直接抄的最小骨架
下面这段代码我用了很多次,结构清晰,适合当作所有练习的起点:
import turtle # 1. 准备画布 screen = turtle.Screen() screen.setup(width=800, height=600) screen.title("turtle demo") screen.bgcolor("white") # 2. 准备海龟 t = turtle.Turtle() t.shape("turtle") t.speed(3) # 3. 画点东西 for _ in range(4): t.forward(120) t.right(90) # 4. 保持窗口 turtle.done()screen.setup(width=800, height=600)里的宽高单位是像素,指的是整个窗口的尺寸,不是绘图区域的尺寸。窗口标题栏、边框会占掉一部分,所以实际能画的区域要略小一点。这个细节在计算图形是否超出边界时很关键。
t.shape("turtle")只是把光标换成一个海龟图标,纯装饰,但新手看到会很有代入感,属于低成本提升体验的小技巧。可选的形状还有"arrow"、"circle"、"square"、"triangle"、"classic"。
t.speed(3)的取值范围是 0 到 10,其中 1 最慢、10 最快,0 是一个特殊值,表示"不延迟"而不是"最快"。这一点后面第 4 节会细讲。
turtle.done()等价于screen.mainloop(),作用就是卡住程序。它在脚本模式下必须写,在交互式环境里则可能引发冲突,这也解释了为什么有人写了done()反而卡死。
提示:如果你看到的是一闪而过的黑窗口,九成是漏了
turtle.done();如果是窗口卡死无响应,九成是交互式环境加done()的冲突,或者是tracer(0)之后忘了update()。
3. 坐标系与角度:搞不懂这两个,图形永远是歪的
3.1 屏幕坐标系和数学坐标系差了半个身子
这是新手最容易栽的地方。数学课上画函数图像,原点在左下角,y 轴朝上;而turtle的画布原点在正中央,x 轴朝右为正,y 轴朝上为正,四个方向的取值范围由画布尺寸决定。
换句话说,goto(0, 0)回到的是正中央,不是左下角。goto(-400, -300)在 800×600 的画布上大约是左下角附近。如果你习惯性地用数学坐标系去推算位置,画出来的东西整体会偏右下。
这里还有一个坑:screen.setup(800, 600)设置的是窗口尺寸,而画布内部的坐标范围默认是screensize()的值,通常是 400×300 上下,但会被窗口尺寸影响。这两个值不是一回事。我一般会在开头显式写一行:
screen.setup(width=800, height=600) screen.setcoords(-400, -300, 400, 300)setcoords(xmin, ymin, xmax, ymax)是强行把画布坐标范围定义成我想要的矩形。这样原点就稳定在中央,左上角是(-400, 300),右下角是(400, -300),坐标心算变得非常直观。这一步不做,你就要靠试错去摸边界,浪费时间。
3.2 朝向、绝对转角、相对转角,三种写法要分清
turtle的默认朝向是 0 度,也就是正右方(东),角度逆时针增加。所以setheading(90)是朝上,setheading(180)是朝左,setheading(270)或setheading(-90)是朝下。
海龟有两个"转"的概念,很多教程混着讲,导致学生迷迷糊糊:
right(angle)/left(angle)是相对转角,基于当前朝向加减,不关心你现在朝哪。setheading(angle)是绝对朝向,直接把朝向设成某个角度,跟之前朝哪没关系。
画正多边形用的是相对转角,因为每次都要在当前位置基础上转固定角度。比如画正方形,每次走完边就right(90),四次之后回到原点,朝向也回到初始值。
而有些场景必须用绝对朝向。比如你要画一条从中心点朝正上方延伸的射线,不管之前海龟朝哪,都写setheading(90),一行搞定,不用算"我还差多少度"。
外角换算是这里最核心的一个知识点。画正 n 边形,每次转的角度是360 / n,不是内角。很多学生第一次画正三角形,写right(60),结果画出来的根本不是三角形。原因很简单:走完一条边后,你要转的是外角,也就是方向改变的量。三角形外角是 120 度,四边形是 90 度,六边形是 60 度。公式就一个:
每次转角 = 360 / n内角是180 - 360/n,两者加起来是 180,这个关系值得让学生自己推一遍,比背公式有用。
| 图形 | 边数 n | 每次转角 | 内角 |
|---|---|---|---|
| 正三角形 | 3 | 120° | 60° |
| 正方形 | 4 | 90° | 90° |
| 正五边形 | 5 | 72° | 108° |
| 正六边形 | 6 | 60° | 120° |
| 正十二边形 | 12 | 30° | 150° |
当 n 大到一定程度,转角趋近于 0,图形看上去就接近圆了。这也是t.circle(radius, steps=n)的本质:用 n 条短边近似圆周。
3.3 相对移动和绝对定位,什么时候用哪个
forward(d)、backward(d)是相对移动,沿着当前朝向走;goto(x, y)是绝对定位,直接跳到某个坐标,中间如果笔是落下的,会画出一条直线。
两者的选择标准是:图形的构造过程是否依赖当前朝向。
画雪花、分形树、螺旋线这类"沿当前方向延伸"的图形,必须用相对移动,因为下一步的方向是由上一步决定的。而画坐标系网格、散点、图标布局这类"我知道每个点在哪"的图形,用goto更直接,不用算角度。
有个小细节值得记一下:goto移动过去之后,海龟的朝向不会改变,还是保持原来的方向。这点和很多人直觉不一样,他们以为goto之后海龟会自动"面朝"目标点。如果你需要它转向目标点,得自己调setheading或者用t.towards(x, y)得到一个角度值再传进去。
target_angle = t.towards(200, 100) t.setheading(target_angle) t.forward(50)towards(x, y)返回的是从当前位置指向目标点的角度,这个函数在画"箭头指向某物"的效果时非常好用,但文档里提得少,很多人不知道。
4. 画笔属性与状态管理
4.1 抬笔落笔:一条不明斜线的元凶
penup()和pendown()(也有简写pu()/pd())控制海龟移动时是否留下痕迹。默认状态是落笔,所以程序一启动就forward,直接画出线来。
新手遇到最多的问题就是"我的图形中间多了一条莫名其妙的斜线"。原因几乎百分百是:移动海龟到新起点时忘了抬笔。比如要画两个分开的正方形,第一个画完后,goto到第二个正方形的起点,如果笔还落着,这条移动路径就被画进去了。
正确的模式应该形成肌肉记忆:
t.penup() t.goto(start_x, start_y) t.setheading(0) t.pendown()抬笔、移动、设定朝向、落笔,四步走完,才开始画。这个"起笔仪式"我建议在封装的每个图形函数开头都写一遍,哪怕有时候看起来多余。
反过来,有些效果恰恰要用连续的落笔路径来做,比如一笔画的多角星、螺旋线,这时候就全程保持落笔,一气呵成。
4.2 线宽、颜色、形状:属性设置的正确顺序
画笔属性有几个:pensize(width)控制线宽,pencolor(color)控制线条颜色,fillcolor(color)控制填充颜色,color(a, b)是同时设置前两者。
颜色可以写英文名("red"、"steelblue"),也可以写十六进制("#FF5733"),还可以传 RGB 三元组,但注意 RGB 的取值是 0 到 1 的浮点数,这是很多人的误区。pencolor((255, 0, 0))会报错或者行为异常,正确写法是pencolor((1.0, 0.0, 0.0))。
import turtle t = turtle.Turtle() t.pensize(3) t.pencolor("#2E86AB") t.fillcolor("#F6BD60")color()有个容易混淆的地方:color("red")会同时把画笔色和填充色都设成红色;而color("red", "blue")是画笔红、填充蓝。这个重载行为不搞清楚,填充效果会莫名其妙。
还有hideturtle()和showturtle():画复杂图形的时候,海龟图标本身会挡住细节,画完一圈之后隐藏掉,画面会干净很多。但注意隐藏之后clear()和reset()的行为不变,只是看不见光标。
4.3 填充:begin_fill 和 end_fill 必须成对
填充是让图形"实心"的关键,用法就三个函数:
t.fillcolor("#F6BD60") t.begin_fill() for _ in range(4): t.forward(100) t.right(90) t.end_fill()begin_fill()记下当前路径,end_fill()把这条路径围成的区域用填充色涂上。这两个必须成对出现,中间画出的路径必须是闭合的。如果路径没有回到起点,turtle会自动用一条直线把首尾连起来再填充,结果往往是一块奇怪的形状。
还有个顺序细节:fillcolor要在begin_fill之前设置。因为begin_fill时会读取当时的填充色,之后再改颜色,已经记录的那条路径填充出来的颜色不会变。
一个实用的技巧是先画边框再填充,或者反过来,取决于你想要的视觉层次。如果想让图形有描边效果,顺序是:设置pencolor和pensize→begin_fill→ 画路径 →end_fill。这样边框和填充都有。
4.4 速度与刷新:为什么你的程序像卡住了
t.speed()的 0 到 10,很多人以为 0 是最慢,正好相反:
| speed 值 | 实际含义 |
|---|---|
| 1 ~ 10 | 从最慢到最快的动画速度 |
| 0 | 关闭动画延迟,直接跳到终点 |
speed(0)是关闭动画,但不等于完全实时渲染;在绘制复杂图形时,窗口刷新仍然拖慢整体速度。真正的性能开关是screen.tracer(0):
screen.tracer(0) # 关闭自动刷新 # ... 大量绘制代码 ... screen.update() # 手动刷新一次tracer(0)关掉自动屏幕刷新后,所有绘制动作在内存里快速完成,最后update()一次性把结果画出来。画分形树、密集螺旋这类几千次移动的图形,用这个技巧能把耗时从几十秒压到一两秒,差别巨大。
注意tracer(n)的参数是"每 n 次动作刷新一次",tracer(0)是特殊值,表示完全不自动刷新。很多人写成tracer(1)以为效果一样,其实tracer(1)是每次都刷新,等于没优化。
提示:
tracer(0)配合update()使用时,如果你的程序需要响应键盘或点击事件,事件的触发可能被拖后。如果发现按键没反应,先确认是不是忘了update()或者在tracer(0)状态下没做刷新。
5. 把基础语法一条条塞进图形里
5.1 循环:从三次重复到参数化
turtle和for循环是天生一对。画正多边形的最小循环:
n = 6 for _ in range(n): t.forward(80) t.right(360 / n)改n就能换图形,这就是最朴素的参数化。学生在这里第一次体会到"变量控制行为"的实际意义——不是背概念,而是改一个数,画面立刻变。
进阶一点,可以引入嵌套循环。比如画一排正方形:
for i in range(5): t.penup() t.goto(-300 + i * 130, 0) t.pendown() for _ in range(4): t.forward(100) t.right(90)外层控制位置,内层控制单个图形,两层各司其职。这个结构训练的是"分层思考":你要先想清楚有多少个图形、每个图形放在哪,再想单个图形怎么画。很多学生写不出来,是因为把两层混在一起想,结果越写越乱。
while循环在海龟作图里用得相对少,但也有合适场景,比如"一直画到超出边界为止":
t.penup() t.goto(-350, 0) t.pendown() step = 10 while t.xcor() < 350: t.forward(step) step += 2xcor()和ycor()分别返回当前 x、y 坐标,配合while做边界判断非常方便。这段代码画出的是间距越来越大的短线,视觉效果类似透视。
5.2 函数:把图形封成可复用的积木
一旦图形超过三个,就必须封函数了。封装的核心原则是:把变化的东西做成参数,把不变的东西写在函数体里。
def draw_polygon(t, n, side): """绘制正 n 边形,边长为 side""" angle = 360 / n for _ in range(n): t.forward(side) t.right(angle)这个函数把t(海龟对象)、n(边数)、side(边长)都做成参数,调用时draw_polygon(t, 5, 80)就是一个正五边形。为什么要传t而不在函数里直接用一个全局海龟?因为传参让函数不依赖外部状态,你可以创建多个海龟同时画不同的东西,这在做复杂构图时非常有用。
再进一步,把"移动到某个位置"也封进去:
def jump_to(t, x, y, heading=None): t.penup() t.goto(x, y) if heading is not None: t.setheading(heading) t.pendown()这个辅助函数能省掉大量重复的四行代码,而且不容易漏抬笔。我自己的习惯是开局就写好这几个工具函数,后面所有练习都基于它们。
5.3 递归:分形树为什么能长出来
递归是基础语法里最抽象的一块,但分形图形是最好的递归教具,因为递归的层级直接对应图形的层级,看得见。
一颗简单的分形树,逻辑就三句话:画一段树干;从树顶分出左右两根小枝,每根比上一级短一点;每根小枝重复上面两步,直到层级用完。
import turtle def branch(t, length, depth, angle=25, shrink=0.72): if depth == 0: return t.forward(length) t.right(angle) branch(t, length * shrink, depth - 1, angle, shrink) t.left(angle * 2) branch(t, length * shrink, depth - 1, angle, shrink) t.right(angle) t.backward(length) screen = turtle.Screen() screen.tracer(0) screen.setup(900, 700) t = turtle.Turtle() t.hideturtle() t.pensize(2) t.color("#3A5A40") t.left(90) t.penup() t.goto(0, -280) t.pendown() branch(t, 130, 8) screen.update() turtle.done()这段代码有几个值得拆的点。t.left(90)是把朝向调成正上方,因为树是往上长的,而海龟默认朝右。递归函数里t.right(angle)之后画右枝,t.left(angle * 2)之后画左枝,最后t.right(angle)把朝向还原——这个"转出去再转回来"的对称结构,是递归图形里最常见的收尾模式。少了最后那行t.right(angle),整棵树会越来越歪。
shrink=0.72是衰减系数,每次缩短 28%。这个值取 0.7 到 0.8 之间视觉效果最好,太小树枝塌成一团,太大树枝伸出屏幕。
depth=8意味着递归 8 层,总共 2^8 = 256 个末端分支。递归深度每加 1,工作量翻倍,所以别一开始就设 12,会等很久。配合tracer(0)和末尾的update(),8 层大概一两秒。
注意:递归一定需要一个明确的终止条件(这里是
if depth == 0: return)。写递归图形最常见的 bug 就是忘了这个判断,程序会一直画下去直到递归深度超限报错,或者直接把内存吃满。
5.4 列表与字典:用数据驱动图形
当图形的位置、大小、颜色需要批量控制时,就该上数据结构了。最典型的是用列表存坐标,然后遍历绘制:
points = [(-200, 0), (-100, 120), (0, 0), (100, 120), (200, 0)] t.penup() t.goto(points[0]) t.pendown() for x, y in points[1:]: t.goto(x, y)这段代码画的是折线,for x, y in points[1:]里的元组解包是语法课上的重点,用在这里很自然。
再复杂一点,用字典列表做样式配置:
shapes = [ {"pos": (-250, 0), "n": 3, "side": 80, "color": "#E76F51"}, {"pos": (-80, 0), "n": 4, "side": 80, "color": "#2A9D8F"}, {"pos": (100, 0), "n": 5, "side": 80, "color": "#457B9D"}, {"pos": (290, 0), "n": 6, "side": 80, "color": "#F4A261"}, ] for item in shapes: jump_to(t, *item["pos"]) t.pencolor(item["color"]) t.fillcolor(item["color"]) t.begin_fill() draw_polygon(t, item["n"], item["side"]) t.end_fill()这一小段同时用到了列表、字典、元组解包、函数调用、循环、键访问,几乎是基础语法的综合演练。而且它体现了一种重要的思路:数据与绘制的分离。图元定义放在数据结构里,绘制逻辑写成一个循环,想加一个图形只需要往列表里塞一条记录,不用改代码结构。
6. 四个从易到难的完整实战
6.1 案例一:通用正多边形与旋转花环
第一个案例在draw_polygon基础上叠加一层旋转,生成的花环效果很出片,学生做完会很有成就感。
import turtle screen = turtle.Screen() screen.setup(800, 800) screen.bgcolor("#1B1B1E") screen.tracer(0) t = turtle.Turtle() t.hideturtle() t.speed(0) t.pensize(2) colors = ["#F72585", "#7209B7", "#3A0CA3", "#4361EE", "#4CC9F0"] for i in range(36): t.pencolor(colors[i % len(colors)]) t.penup() t.goto(0, 0) t.pendown() t.setheading(i * 10) t.penup() t.forward(80) t.pendown() for _ in range(4): t.forward(90) t.right(90) screen.update() turtle.done()关键在t.setheading(i * 10)——36 个正方形,每次朝向差 10 度,正好转满 360 度。它们都从距离中心 80 像素的位置开始画,形成一个完美的旋转对称图案。改i * 10为i * 15就只有 24 个图形,角度间隔也变宽,视觉效果完全不同。这个案例的意义是让学生体会角度间隔与数量之间的约束关系:36 × 10 = 360,不是随便写的。
6.2 案例二:用海龟画折线图,顺便理解数据
turtle虽然是绘图入门工具,但用来画简易折线图完全够用,而且能顺势引出"坐标映射"这个概念。
import turtle data = [12, 34, 28, 51, 47, 63, 58, 72, 66, 80] screen = turtle.Screen() screen.setup(800, 600) screen.tracer(0) # 画坐标轴 axis = turtle.Turtle() axis.hideturtle() axis.speed(0) axis.penup() axis.goto(-320, -220) axis.pendown() axis.forward(640) axis.penup() axis.goto(-320, -220) axis.pendown() axis.left(90) axis.forward(480) # 画折线 t = turtle.Turtle() t.hideturtle() t.speed(0) t.pencolor("#E63946") t.pensize(3) x0, y0 = -300, -200 step_x = 60 scale_y = 4.5 first = True for i, v in enumerate(data): x = x0 + i * step_x y = y0 + v * scale_y if first: t.penup() t.goto(x, y) t.pendown() first = False else: t.goto(x, y) t.dot(6, "#1D3557") screen.update() turtle.done()这里的核心是两行映射计算:x = x0 + i * step_x把数据下标映射到屏幕横坐标,y = y0 + v * scale_y把数值映射到屏幕纵坐标。scale_y = 4.5是缩放系数,先看数据最大值 80,乘以 4.5 得 360,加上起点-200得 160,没超出画布上边界(约 300),这个系数就合适。这个"先算极值、再定缩放、最后验证不越界"的流程,是所有可视化工作的基础,只不过turtle让你手动做了一遍,反而理解得更深。
first标志位是为了第一点只定位不画线,后面才连线。这个"首点特殊处理"的模式在画线段时反复出现,值得记住。
6.3 案例三:键盘控制的小海龟,理解事件驱动
前面的程序都是"从上到下跑完结束",这个案例要引入事件,程序结构会发生变化。
import turtle screen = turtle.Screen() screen.setup(700, 500) screen.title("keyboard control") screen.tracer(0) t = turtle.Turtle() t.shape("turtle") t.color("#2A9D8F") t.pensize(3) def move_forward(): t.forward(20) screen.update() def move_backward(): t.backward(20) screen.update() def turn_left(): t.left(30) screen.update() def turn_right(): t.right(30) screen.update() def clear_all(): t.clear() t.penup() t.home() t.pendown() screen.update() screen.listen() screen.onkey(move_forward, "Up") screen.onkey(move_backward, "Down") screen.onkey(turn_left, "Left") screen.onkey(turn_right, "Right") screen.onkey(clear_all, "c") screen.update() turtle.done()screen.listen()是必须的,它让画布开始接收键盘事件,很多人漏了这行,然后按键毫无反应。onkey只是注册回调,不调用listen就不生效。
回调函数必须无参数,这是onkey的约束。如果你写def move_forward(distance),注册时不会报错,但按下去会抛出参数错误。这个坑很隐蔽,因为错误发生在按键时,不在启动时。
screen.tracer(0)和screen.update()的配合在这里尤其重要。如果用了tracer(0)却在回调里忘了update(),海龟的位置变了,屏幕却没动,看起来像按键失灵。这类问题排查起来很折磨人,根源就是刷新机制没搞清。
6.4 案例四:鼠标点击落点,把事件和数据串起来
鼠标事件和键盘事件结构类似,但多了坐标参数,正好用来演示"回调函数带参数"的处理方式。
import turtle screen = turtle.Screen() screen.setup(700, 500) screen.bgcolor("#F1FAEE") pen = turtle.Turtle() pen.hideturtle() pen.speed(0) pen.width(2) marks = [] def on_click(x, y): marks.append((x, y)) pen.penup() pen.goto(x, y) pen.pendown() pen.dot(10, "#E63946") pen.penup() pen.goto(-330, 220) pen.pendown() pen.clear() pen.write(f"count: {len(marks)}", font=("Arial", 14, "normal")) screen.onclick(on_click) turtle.done()onclick的回调接收x, y两个参数,这是模块自动传的,不需要你手动传。marks列表记录所有点击位置,既用于计数显示,也可以后续导出做别的处理。
有个细节:pen.clear()会清掉这只笔画的所有内容,包括刚才的圆点,所以这段代码实际上每点一次就会重绘。真正要保留痕迹的话,得用两只海龟,一只专门画点,一只专门写文字,清理时只清后者。这个"分图层"的意识,是turtle里做复杂界面的关键技巧,虽然模块本身没有图层概念,但用多个 Turtle 对象可以模拟出来。
dot_pen = turtle.Turtle() dot_pen.hideturtle() dot_pen.speed(0) text_pen = turtle.Turtle() text_pen.hideturtle() text_pen.speed(0)两只笔各管一摊,互不干扰,text_pen.clear()不会影响dot_pen画的点。
7. 常见问题排查实录
7.1 窗口一闪而过、卡死不动、出现两个窗口
这三种现象是最高频的,我一次性说清楚。
一闪而过:程序最后一行执行完,进程退出,窗口跟着销毁。加turtle.done()或者screen.mainloop()就好。
卡死不动:多半是在 Jupyter 类环境里跑了带done()的代码,内核的事件循环和外部的窗口循环冲突。换到命令行跑.py文件,或者去掉done()改用screen.update()手动刷新(仅限支持的环境)。
出现两个窗口:一种是代码里既turtle.Screen()又turtle.getscreen(),有两种创建方式混用;另一种是某处turtle.Turtle()在 Screen 创建之前就调用了,隐式创建了一个默认屏幕,之后你又显式建了一个。解决办法是把 Screen 的创建放在最前面,所有 Turtle 都从它派生。
还有种情况是窗口打开了但完全空白。先检查screen.tracer(0)之后有没有调用update(),这是最常见的原因。
7.2 图形歪了、连到奇怪的地方、坐标对不上
这一类问题的排查,我通常按"抬笔 → 朝向 → 坐标系"三步走。
多出一条斜线:检查移动前是否penup()。尤其是封装函数里,函数开头的抬笔容易被漏掉。
图形朝向不对:区分right和setheading。如果你写的是setheading(90)之后又right(45),两个是叠加的,最终朝向 45 度。排查时可以在关键步骤加一句print(t.heading()),直接看当前角度值。
坐标整体偏了:考虑是不是没设setcoords,导致原点位置随窗口尺寸变化。写死setcoords(-400, -300, 400, 300)之后,坐标就稳定了,所有位置计算可以离线纸上算好。
图形超出了可见区域:算一下最远点的坐标,和画布边界比较。800×600 的窗口,实际可绘制区域大约横向 ±400、纵向 ±300 以内比较安全。边距留 20 到 30 像素,视觉上更舒服。
7.3 画得慢、程序像卡住了
先确认speed的值。speed(0)关闭动画延迟,比speed(10)快很多,但很多教程只写到 10。
再确认是否用了tracer(0)。对于超过 500 次移动的图形,这是必须的:
screen.tracer(0) # 绘制... screen.update()如果还是慢,检查有没有在循环里频繁调用write()或者dot()。文字渲染和点绘制比线段昂贵得多,几百个write会让程序明显变卡。解决办法是减少文字数量,或者把文字汇总到一次write里。
还有一种"慢"是错觉:递归深度设得太大,实际是在跑几万次移动。这时候优化渲染没用,得降低深度。depth每加 1,工作量翻倍,这个指数关系要牢记。
7.4 填充失败、颜色异常、报 TclError
填充变成奇怪的形状:大部分是路径没闭合。检查循环画完之后,海龟是否回到了起点。正多边形只要边数、边长、转角都正确,一定会回到起点;如果转角算错,就会在起点附近留个缺口,end_fill连线之后就露馅了。
填充色没生效:检查fillcolor是否在begin_fill之前设置。另外确认begin_fill和end_fill是否配对,中间有没有提前return导致end_fill没执行。
报 TclError: unknown color name:颜色拼写错误。"gray"和"grey"都支持,但"steel-blue"这种带连字符的需要查表确认。最稳的做法是用十六进制色值,不依赖名称表。
RGB 元组不生效:确认用的是 0 到 1 的浮点。(255, 0, 0)是错的,(1.0, 0.0, 0.0)才对。
7.5 常见问题速查表
| 现象 | 最可能原因 | 处理方式 |
|---|---|---|
| 窗口一闪而过 | 缺少turtle.done() | 脚本末尾补上 |
| 窗口卡死无响应 | 交互式环境 +done()冲突 | 改用命令行跑.py文件 |
| 画面空白 | tracer(0)后没update() | 补screen.update() |
| 多出斜线 | 移动前未penup() | 移动前抬笔 |
| 正多边形画不成 | 转角用了内角 | 改成360 / n |
| 图形整体偏右下 | 原点理解错 | 显式setcoords |
| 填充形状怪异 | 路径未闭合 | 检查转角与边数 |
| 颜色报错 | 色名拼写或 RGB 格式 | 用#RRGGBB |
| 按键无反应 | 漏了screen.listen() | 补上并确认回调无参 |
| 绘制极慢 | 未用tracer(0) | 加装并末尾update() |
| 递归报错超限 | 缺少终止条件 | 补if depth == 0: return |
提示:把这张表打印出来贴在旁边,出问题先扫一眼,能省掉一大半现场调试时间。我见过太多人卡在"忘抬笔"和"忘
update"这两件事上,一卡就是半小时。
8. 踩过的坑和几条实操心得
8.1 那些反直觉的细节
backward()会画出线来。很多人以为后退是"回到原位",其实它就是沿着当前朝向的反方向平移,笔落着就照样画线。要"退回去不留痕迹",必须抬笔。
circle()的方向和正负号有关。circle(radius)默认沿当前朝向的左侧画圆,也就是逆时针;circle(-radius)画在右侧。circle(radius, extent)的extent是画多少度,circle(100, 180)画半圆。这个函数比看起来灵活,circle(100, steps=5)直接得到以 100 为外接圆半径的正五边形,省掉自己算转角的麻烦。
goto不改变朝向。前面提过,但值得再说一遍,因为直觉上太容易搞错。
clear()和reset()不一样。clear()只清掉画的内容,海龟的位置、朝向、属性都保留;reset()是把一切恢复初始状态,连颜色、线宽都重置。做"清空画布"功能时,通常想要的是clear(),但要顺手把位置复位,就得两个配合。
hideturtle()之后write()仍然有效。隐藏光标不影响绘制,只是看不见那只小海龟,画面更干净。
8.2 带人练习时最容易出现的四个误区
误区一:一上来就抄完整作品。直接给一段 60 行的旋转花环代码,新手跑出来很惊艳,但让他改个参数就懵了。正确的路径是三步:先画一条线,再画一个正方形,最后才谈组合。每一步都必须自己独立写出来。
误区二:函数封装得太早。有些教程第一节课就教封装draw_polygon,学生照着敲能跑,但并没有理解为什么要封。我的做法是先让他重复写三遍正方形的代码,写到第三遍他自己就烦了,这时候再讲函数,接受度完全不一样。
误区三:坐标全靠试。看到图形位置不对,就改一个数重新跑,反复十几次。这不是调试,这是碰运气。正确的做法是先在纸上算好坐标,或者用print(t.pos())把实际位置打出来,对比预期。把肉眼比对变成数值比对,效率差好几倍。
误区四:递归还没搞懂就画分形。分形树的代码很短,但它同时叠加了递归、状态恢复、参数衰减三个难点。如果学习者对"函数调用栈"还没有概念,画出来的树只会是照抄。我的建议是先写一个只有两层的小树,把递归调用展开成手写的两层调用,看着两段代码一一对应,再推广到 8 层。
8.3 从海龟往后走的几条路
turtle的生命周期不长,通常两三周就够了。之后往哪走,取决于你想继续哪个方向。
想做数据可视化,往下走是matplotlib。好消息是,你在turtle里手算过的坐标映射、缩放系数、坐标轴绘制,在matplotlib里全部有对应概念,只是它帮你封装好了。那套"先看极值、再定比例、最后验证不越界"的思路可以原样搬过去。
想做交互程序或游戏,往下走是pygame。你在turtle里写的事件回调、tracer刷新控制、多对象管理,都是pygame里游戏循环的雏形,只是pygame把刷新控制权完全交到你手上,要求更高。
想做面向对象,可以把海龟封装成类。Turtle本身就是一个对象,你完全可以写一个class Painter持有自己的海龟、颜色方案、绘制方法,这是从过程式到面向对象最自然的过渡路径。
我自己的体会是,turtle的价值不在于教会你画图,而在于它把"代码即过程"这件事变得可感知。你写的每一行都会在屏幕上留下痕迹,写错了痕迹也在,改对了痕迹就变。这种即时因果,是后面所有抽象内容都替代不了的。所以带人的时候,我宁可在这上面多花两周,把抬笔、朝向、坐标、刷新这几件事磨透,后面学列表、字典、类的时候,才有那个"我大概知道程序在干什么"的底子。
最后分享一个小技巧:如果你想让程序看起来"像动画一样"流畅,又不想被speed的档位限制,可以试试screen.tracer(0)配合在循环里按固定间隔调update(),比如每画 20 个元素刷新一次。这个做法在画螺旋和粒子效果时特别顺手,画面会有一种逐渐生长的感觉,比一次性蹦出来好看得多。