## 引言
这是第三章「流程控制」的第 9 篇。前八篇依次建立了分支结构(`if/elif/else`)、模式匹配(`match`)、`while`、`for` 与 `range`、`break`/`continue`/`else`、嵌套组合、推导式,并在第 8 篇用 FizzBuzz 家族与猜数字完成了第一次实战。本篇延续实战路线,做两件必然要做的事:其一,**图案打印**——把「在终端画图形」当作训练循环与分支的标尺,用行-列建模(row-column modeling)方法从行号推导公式,程序化地画出矩形、三角形、菱形与空心图案;其二,**交互式菜单**——用 `while True` + `input` + 分支分发搭出命令行工具的标准外壳。两种任务最后合体:图案打印作为菜单的一个功能项,与第 8 篇的猜数字并列,组成一个像样的命令行小应用。读者应先掌握:`while` 与 `for` 的写法(第 3、4 篇)、`break`/`continue` 的语义(第 5 篇)、嵌套循环的层次关系(第 6 篇)、字符串乘法与 `join`(第二章字符串篇)、真值测试与短路(第二章布尔篇)。下一篇是本篇的测验篇,本篇正是测验的重点取材对象。
## 概念与原理:行-列建模
图案打印的难点不在语法,而在「把图形翻译成公式」。翻译的起点是认清终端的本质:**print 永远整行输出,字符只能按行产生**。控制台把每一行视为一个字符串,行与行之间没有可供回退的绘制指令,因此任何图案程序的外形都被限死为「外层循环管行,行内逻辑管这一行长什么样」。反过来说,只要把「第 i 行是什么」写成公式,整个图案程序就只剩一层循环加一行构造。
图案本质上是一张二维网格(grid):用二元组 `(i, j)` 标识位置,i 是行号(从 0 起),j 是列号。画图就是对每个 `(i, j)` 回答「这里放星号还是空格」。这一步可以先用「让每个格子说话」的小程序把网格显形:
```python
# 行-列建模:先让每个格子自己说话
for i in range(3):
print(" ".join(f"{i},{j}" for j in range(4)))
```
```text
0,0 0,1 0,2 0,3
1,0 1,1 1,2 1,3
2,0 2,1 2,2 2,3
```
这里的 `join` 与生成器表达式来自第二章与本章第 7 篇:本行四个格子 `(i, j)` 逐个变成字符串,再用空格串成一行。程序与输出严格对应——每行 `(i, j)` 中 j 从 0 走到 3,行号 i 控制「第几行」。绝大多数教科书图案还有一个更强的性质:**一行之内只有一块连续的星号**。于是每行可以完全由两个数决定——前置空格数 `S(i)` 与星号数 `T(i)`。整幅图案的代码因此坍缩为一层循环加两个公式,先写公式再写代码,是图案打印的总方法论。
公式怎么保证正确?三步自检法:① 数清总行数 n;② 写下「行号 i 与星号数、缩进量的关系式」;③ 把边界行(i = 0 与 i = n - 1)代入公式手推,输出必须与图中两端的行吻合。三类构造工具在本章都已就位:字符串乘法 `"*" * k` 一次产生 k 个字符,是整行构造的首选;字符串拼接在循环里逐字符组装,机制透明但每行产生多次中间对象;`join` 适合把字符序列合成行。值得算一笔复杂度账:画 n 行、每行最长约 n 个字符,字符总量是 O(n²) 量级;对每行用乘法一次构造,行内便不再需要内层循环,这是「循环层数越少,代码越容易读」的直接体现。
## 操作与实现:五种图案的公式推导
### 矩形:先整行,再整幅
矩形是「行公式与行号无关」的特例——每行都是同样长度的星号行,`S(i) = 0`,`T(i) = m`。先看乘法写法,再看等价的内层循环写法:
```python
n, m = 3, 6
for _ in range(n): # 外层循环:行
print("*" * m) # 整行一次构造
print()
for i in range(n): # 等价写法:内层循环逐字符
line = ""
for j in range(m):
line = line + "*"
print(line)
```
```text
******
******
******
******
******
******
```
两种写法输出完全相同;第二种把「行 = 字符序列」的本质摊开了:内层循环逐列把星号拼进 `line`。但第二章字符串篇已经讲过,不可变字符串反复拼接会不断产生中间对象,所以工程上「一次乘法构造整行」才是矩形乃至所有实心图案的标准答案。变量名用 `_` 表示「循环次数无关紧要」,这是第 4 篇的惯用法;矩形是五种图案里唯一「行的内容与行号无关」的,所以外层循环用 `_` 占位,内层才真正消费列号 j。
### 直角三角形:星数 = 行号 + 1
左对齐的直角三角形(直角在左下)是最直接的递增形状:第 i 行有 `i + 1` 个星号:
```python
n = 5
for i in range(n):
print("*" * (i + 1))
```
```text
*
**
***
****
*****
```
公式 `T(i) = i + 1`;代入边界行验证:i = 0 时 1 个星号(顶端),i = 4 时 5 个星号(底边),完全吻合。把 `range(n)` 换成 `range(n, 0, -1)` 立即得到倒置版本——同一公式、不同步长,这正是第 4 篇 `range` 三参数形式的用武之地。
### 等腰三角形:居中公式的推导
等腰三角形比直角三角形多一个维度:每行除了星号还要前置空格把它推到中间。设总行数 n,底边宽度为 `2n - 1`;第 i 行的星号数显然是 `2i + 1`(从 1 起每次加 2)。要居中,两侧的空格各占 `(2n - 1 - (2i + 1)) / 2 = n - 1 - i` 个,左侧放这些空格即可:
```python
n = 5
for i in range(n):
spaces = n - 1 - i # 左侧空格数
stars = 2 * i + 1 # 本行星号数
print(" " * spaces + "*" * stars)
```
```text
*
***
*****
*******
*********
```
边界行自检:i = 0 时 `spaces = 4`、`stars = 1`,顶端一个星号居中;i = 4 时 `spaces = 0`、`stars = 9`,底边打满。这个公式是后面所有居中图案的地基,值得背下来:**空格数随行号递减、星号数随行号以 2 递增**。
### 菱形:到中心行的距离
菱形可以看成「上半场递增、下半场递减」的等腰三角形。设中心行是第 `n // 2`(n 为奇数时恰好是中间行),记 `d = |i - 中心行|` 为本行到中心的距离,则星号数 `2d + 1`、前置空格数 `n - 1 - d`。用 `min(i, n - 1 - i)` 一步算出距离,一行代码写完整幅图:
```python
n = 5
for i in range(n):
d = min(i, n - 1 - i) # 本行距中心行的距离
print(" " * (n - 1 - d) + "*" * (2 * d + 1))
```
```text
*
***
*****
***
*
```
`min(a, b)` 是内建函数,返回两个数中较小者:i 从 0 走到 n - 1,`min(i, n - 1 - i)` 先等于 i(上半场距离递增)、在中心行之后等于 `n - 1 - i`(下半场距离递减),两个公式在中间行自然交棒。等效的分支写法是 `d = i if i <= n // 2 else n - 1 - i`——公式版本比分支版本短且不易错,这正是「先建模、后编码」的收益。
### 空心图案:边界判断
空心图案在「一块连续星号」的假设上开洞:星号只出现在边界上。空心矩形(n 行 × 2n 列)的规则是:第一行与最后一行整行星号,其余行只有首尾两个星号:
```python
rows, cols = 4, 10
for i in range(rows):
if i == 0 or i == rows - 1:
print("*" * cols) # 顶边与底边
else:
print("*" + " " * (cols - 2) + "*") # 两侧壁
```
```text
**********
* *
* *
**********
```
空心三角形(腰长 n)则要求逐字符判断:每行有 `2i + 1` 个位置,左壁(j = 0)、右壁(j = 2i)、底边(i = n - 1)放星号,其余放空格。字符不再连续,字符串乘法让位给内层循环:
```python
n = 6
for i in range(n):
line = ""
for j in range(2 * i + 1):
if j == 0 or j == 2 * i or i == n - 1:
line += "*" # 左壁 / 右壁 / 底边
else:
line += " "
print(" " * (n - 1 - i) + line)
```
```text
*
* *
* *
* *
* *
***********
```
注意底边 `i == n - 1` 是整行星号,这一行恰好覆盖了 `j == 2i` 的右壁条件,逻辑顺序无冲突。三种边界判断合在一个 `or` 里,对应第 2 章比较与逻辑运算的短路求值语义:`j == 0` 为真时不再看后面两个比较。
## 交互式菜单:while True 主循环
图案打印是「内容」,交互式菜单是「外壳」——命令行工具的标准结构。外壳由四个要素组成:**主循环**(main loop,`while True` 让程序不死)、**提示与读取**(`input` 拿用户命令)、**分发**(dispatch,`if/elif` 把命令字符串映射到对应动作,第 1 篇分支结构的用武之地)、**退出通道**(`break`,第 5 篇循环控制三件套之一)。一个最小菜单只有十几行:
```python
while True:
cmd = input("命令(1 打印 2 帮助 0 退出):").strip()
if cmd == "1":
print("正在打印……")
elif cmd == "2":
print("帮助:输入 1 打印,输入 0 退出")
elif cmd == "0":
print("再见!")
break
else:
print("未知命令")
```
```text
命令(1 打印 2 帮助 0 退出):2
帮助:输入 1 打印,输入 0 退出
命令(1 打印 2 帮助 0 退出):1
正在打印……
命令(1 打印 2 帮助 0 退出):9
未知命令
命令(1 打印 2 帮助 0 退出):0
再见!
```
上面是一次真实运行(Python 3.12 实测,输入行是程序提示后我键入的内容)。三个细节值得沉淀:其一,`input` 返回的一定是字符串(第二章类型转换篇),命令比较时必须与字符串字面量比较,`if cmd == 1` 恒为假;其二,`elif` 的顺序就是分发表(dispatch table)的顺序,`0` 的退出分支必须能 break——`break` 只终止最近一层循环,这里恰好是主循环;其三,任何不在白名单(whitelist)里的输入都会被 `else` 兜住并回到循环顶部继续等待,程序不会崩、不会死锁。在循环末尾没有任何语句时,一轮结束自动回到 `while True` 的顶部——这是主循环「永远问下一个问题」的动力来源。
## 综合演示:图案打印成为菜单的功能项
现在把两部分合体。图案打印是「功能」,猜数字(第 8 篇的实战)是另一个「功能」,菜单是「分发器」。要做到这一点,第一次需要一种新语言机制:**函数**(第四、五章会系统讲 `def` 与参数传递,这里先借最朴素的形态——无参数、定义即用)。没有函数时,每个功能都要把整段代码抄进对应的 `if` 分支,菜单每加一项就膨胀一倍;有了函数,「做什么」与「怎么做」分离,菜单只剩一行调用。这也是为什么几乎所有真实命令行工具都长成「函数库 + 主循环」两层。完整程序如下(文件 menu_demo.py,Python 3.12 实测):
```python
def rect(n):
for _ in range(n):
print("*" * (2 * n))
def tri(n):
for i in range(n):
print(" " * (n - 1 - i) + "*" * (2 * i + 1))
def diamond(n):
for i in range(n):
d = min(i, n - 1 - i)
print(" " * (n - 1 - d) + "*" * (2 * d + 1))
def hollow(n):
for i in range(n):
if i == 0 or i == n - 1:
print("*" * (2 * n))
else:
print("*" + " " * (2 * n - 2) + "*")
def guess():
target = 42
tries = 0
while True:
tries += 1
s = input("猜一个 1~100 的整数:").strip()
if not s.isdigit():
print("只会读数字,请重猜")
continue
if int(s) < target:
print("小了")
elif int(s) > target:
print("大了")
else:
print(f"猜对了!共用 {tries} 次")
break
print("===== 图案工坊 =====")
while True:
print("\n1. 图案打印 2. 猜数字 0. 退出")
choice = input("请选择:").strip()
if choice == "0":
print("再见!")
break
if choice == "1":
kind = input("图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):").strip()
size = input("行数(正整数):").strip()
if not size.isdigit() or int(size) < 1:
print("行数必须是正整数")
continue
n = int(size)
if kind == "1":
rect(n)
elif kind == "2":
tri(n)
elif kind == "3":
diamond(n)
elif kind == "4":
hollow(n)
else:
print("没有这个图案编号")
elif choice == "2":
guess()
else:
print("无效选项(0~2)")
```
程序共 60 行:前五个函数是「图案函数库」,其中 `guess` 把第 8 篇的猜数字原样收编;主循环是「分发器」。两处设计值得说明:其一,矩形宽度取 `2n`,让「行数 n」一个参数同时决定所有图案的规模,菜单不必再问两个数字;其二,行数校验用 `not size.isdigit() or int(size) < 1`——`isdigit()` 是字符串方法,当字符串非空且全是数字字符时返回 `True`,由于 or 短路求值,`int(size)` 只在确认是纯数字后才会执行,非数字输入永远不会触发 `ValueError`。一段完整会话(真实运行):
```text
===== 图案工坊 =====
1. 图案打印 2. 猜数字 0. 退出
请选择:9
无效选项(0~2)
1. 图案打印 2. 猜数字 0. 退出
请选择:2
猜一个 1~100 的整数:50
大了
猜一个 1~100 的整数:30
小了
猜一个 1~100 的整数:abc
只会读数字,请重猜
猜一个 1~100 的整数:42
猜对了!共用 4 次
1. 图案打印 2. 猜数字 0. 退出
请选择:1
图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):3
行数(正整数):5
*
***
*****
***
*
1. 图案打印 2. 猜数字 0. 退出
请选择:1
图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):4
行数(正整数):4
********
* *
* *
********
1. 图案打印 2. 猜数字 0. 退出
请选择:1
图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):5
行数(正整数):3
没有这个图案编号
1. 图案打印 2. 猜数字 0. 退出
请选择:1
图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):1
行数(正整数):0
行数必须是正整数
1. 图案打印 2. 猜数字 0. 退出
请选择:1
图案编号(1 矩形 2 三角形 3 菱形 4 空心矩形):2
行数(正整数):3
*
***
*****
1. 图案打印 2. 猜数字 0. 退出
请选择:0
再见!
```
这段会话覆盖了程序的所有分支:非法菜单项(9)、合法菜单项(2 与 1)、猜数字的偏大/偏小/非数字/猜中、菱形、空心矩形、非法图案编号(5)、非法行数(0)、三角形与退出。注意非法输入从来不会让程序崩溃,而是被白名单或守卫拦下——这是「外壳」质量的分水岭。把第 8 篇的猜数字与图案打印并排在同一菜单下,两种任务的衔接也讲清楚了:**猜数字的循环在「数值区间」上行进(大了/小了),图案打印的循环在「字符网格」上行进(行号/列号)**,它们本质是同一套控制结构的两种叙事。
## 易错点与陷阱
**陷阱一:差一错误。** 居中公式里左侧空格数是 `n - 1 - i`,写成 `n - i` 会让每行多一个空格,顶端看起来整体右移一列:
```python
print("错误公式 (n - i):")
for i in range(3):
print(" " * (3 - i) + "*" * (2 * i + 1))
print("正确公式 (n - 1 - i):")
for i in range(3):
print(" " * (3 - 1 - i) + "*" * (2 * i + 1))
```
```text
错误公式 (n - i):
*
***
*****
正确公式 (n - 1 - i):
*
***
*****
```
差一个字符的差值在 i = 0 时最明显:正确版顶端应在最中列,错误版往右挪了一格。预防手段就是边界行自检——公式写完后,先把 i = 0 与 i = n - 1 代入手推。
**陷阱二:input 的结果是字符串,与整数比较恒为假。** `input("请选择:")` 返回 `"1"` 而非 `1`,`if choice == 1` 永远不成立,所有命令都会落到 `else` 分支——程序不报错,但菜单形同虚设。必须与字符串字面量比较(`choice == "1"`),或先 `int()` 再比较。第二种写法的风险在陷阱四。
**陷阱三:break 的缩进位置。** `break` 一旦执行就终止最近一层循环。把它误写到 `if` 外面(顶格),菜单会「只响应一次命令就退出」:
```python
cmd = "1"
while True:
if cmd == "1":
print("执行命令")
break # 错误缩进:无条件退出,循环形同虚设
print("菜单只运行了一次就退出")
```
```text
执行命令
菜单只运行了一次就退出
```
第 5 篇强调过:`break` 的位置必须与它要守卫的条件严格对齐,逐级对齐缩进是最快的排查手段。
**陷阱四:isdigit 与 int 对空白的容忍度不同。** `int(" 3")` 能解析(int 会跳过首尾空白),但 `" 3".isdigit()` 是 `False`(空格不是数字字符),`"".isdigit()` 也是 `False`。所以正确的输入管线是「先 strip 去空白 → 再 isdigit 判数字 → 最后 int 转换」,顺序不可调换,且空输入天然被 isdigit 拦下:
```python
print(" 3".isdigit()) # False:带空格的"3"不是纯数字串
print(int(" 3")) # 3:int 容忍首尾空白
print("".isdigit()) # False:空串不是数字
```
```text
False
3
False
```
**陷阱五:空心图案的字符数不配平。** 空心矩形中间行的 `"*" + " " * (cols - 2) + "*"`,字符总数必须是 1 + (cols - 2) + 1 = cols,与顶行 `"*" * cols` 严格相等;写成 `cols - 1` 会让右壁左移一列,图案歪掉。自检办法是打印每行的 `len` 与 cols 比对——矩形 4×10 的四行理应全部输出 `10`,这一检查在综合演示代码上逐行成立。
## 小结
本篇完成了第三章的第二次实战闭环。行-列建模把「画图形」翻译成「写公式」:数清行数、写出行号与星号数/空格数的关系、用边界行自检,五种图案(矩形、直角三角形、等腰三角形、菱形、空心矩形与空心三角形)全部在这套方法下一行代码成形。交互式菜单给出命令行工具的标准外壳:`while True` 主循环、`input` 读取、`if/elif` 分发、`break` 退出,白名单与守卫让非法输入不再致命。两者合体后,第 8 篇的猜数字也收编为菜单功能项——图案、游戏、菜单三种「叙事」用的都是同一组控制结构,这正是本章的主题。下一篇综合测验将用选择题与编程题检验这些能力。
## 练习与思考题
1. **倒置三角形**:写程序输出 n 行的倒等腰三角形(第 0 行的星号数为 `2n - 1`,此后每行减 2)。提示:把等腰三角形的公式反转——`stars = 2 * (n - 1 - i) + 1`,空格数仍为 `n - 1 - i`。运行并核对 i = 0 与 i = n - 1 两行。
2. **空心菱形**:在菱形公式基础上挖空:每行在星号区间 `[0, 2d]` 内,只当 `j == 0` 或 `j == 2d` 时放星号(d = 0 的顶行与底行只有单个星号),其余放空格。参考空心三角形的内层逐字符写法,先手推 n = 5 的形状再编码。这个练习是把「实心公式」与「边界判断」两种方法合用的关键一步。
3. **菜单扩容**:为「图案工坊」增加第 5 个功能项「数字金字塔」(第 i 行打印 `str(i) * i`,即 1、22、333……)。写出需要改动的最小代码集合(答案方向:菜单印刷加一行、`if/elif` 分发加一个分支、函数库加一个函数),并说明为什么三个位置都要改、漏改任何一个会发生什么。
4. **思考题**:综合演示里矩形宽度被固定为 `2n`(列数随行数变化)。如果希望矩形宽高独立(用户分别输入行数与列数),`rect` 函数与主循环各需要改几行?改动后 `n` 与 `cols` 两个参数如何传给 `rect`,菜单的分发结构是否有变化?