🍓 第六章 开发 Python 程序
📄 一句话概括

上一节的程序已经能判断、能循环了,但代码一多就变成一大坨。 本节给两样"整理工具":函数(把一段活儿打包,起个名字,随叫随用)和模块(把别人写好的整套函数拿来直接用)。 本节也是全章最容易踩坑的一节——因为"装一个第三方库"这件事,在现在的树莓派上和网上老教程写的完全不一样了。

📝 本节和前面几节的关系

前面几节写的代码都是"平铺直叙"的:从上往下一条条执行。函数是第一次"给代码分层"——6-4 猜数字游戏里那三段逻辑(出题、判断、主流程),本节会把它们拆成三个函数。 另外本节会正经讲 import——其实你上一节已经偷偷用过一次了(import random),当时说"下一节正式讲",就是现在。 读法建议:本节的"第二节(模块)"里有一段关于装库的大坑,是本节最值钱的内容——中文教程在这个点上有 90% 是过期的。

一、函数:把一段活儿打包

1.1 为什么需要函数:看一段"复制粘贴"的代码

假设要算三次圆面积:

r1 = 5
area1 = 3.14159 * r1 * r1
print(f"半径 {r1} 的圆,面积是 {area1:.2f}")

r2 = 10
area2 = 3.14159 * r2 * r2
print(f"半径 {r2} 的圆,面积是 {area2:.2f}")

r3 = 2.5
area3 = 3.14159 * r3 * r3
print(f"半径 {r3} 的圆,面积是 {area3:.2f}")

问题很明显:3.14159 * r * r 抄了三遍。

用函数改造:

import math

def circle_area(r):
    """算圆的面积"""
    if r < 0:
        return None
    return math.pi * r * r

for r in [5, 10, 2.5]:
    print(f"半径 {r} 的圆,面积是 {circle_area(r):.2f}")
💡 一句话理解函数的价值

函数 = 给一段代码起个名字。 好处就三条:① 不用抄(改一处就全改);② 逻辑有名字了,一眼看懂在干什么;③ 能反复用。 这一条其实你在 函数的定义 里见过——C++ 和 Python 在这一点的思想完全一样,只是写法不同。

1.2 定义与调用:def 和"打括号"

def say_hello():                # ← 定义:def 函数名(参数):  ← 冒号别忘
    print("你好!")              # ← 函数体:必须缩进 4 格

say_hello()                     # ← 调用:函数名 + 括号
say_hello()                     # 想用几次就叫几次

三件事要分清:

写法 意思
def say_hello(): 定义它(只是"教会"Python 这件事,不执行)
say_hello() 调用它(真的执行一次)
say_hello 只是提到这个名字(没有括号,不执行)
⚠️ 括号忘掉的后果很隐蔽
say_hello      # 没报错,也什么都没发生

不报错、不执行、静默无事——这是最难查的一类问题(3-5 讲过的"安静失败")。 记住:函数名不写括号,就是"提到它",不是"用"它。

1.3 参数:把"变化的部分"留成口子

def greet(name, times):
    for _ in range(times):          # _ 表示"这个变量我用不上,占个位"
        print(f"你好,{name}!")

greet("波仔", 2)

输出:

你好,波仔!
你好,波仔!

为什么参数很重要:因为函数要"能通用"。如果你把 波仔 写死在函数里,那这个函数就只能对一个人说你好。

1.4 返回值:return——这是最需要分清的一点

def add(a, b):
    return a + b                # 把结果"交出去"

result = add(3, 5)              # 用变量接住
print(result)                   # 8
⚠️ `return` 和 `print` 是两件完全不同的事

这是新手最大的困惑点,必须分清:

print(...) return ...
干什么 给人看(显示在屏幕上) 给程序用(把结果交回调用处)
结果能继续算吗 不能 能
打比方 用广播喊出来 把东西递到你手里
def bad_add(a, b):
    print(a + b)              # 只能看,拿不走

def good_add(a, b):
    return a + b              # 能拿走

x = bad_add(3, 5)             # 屏幕上显示 8
print(x)                      # 但 x 是 None!因为函数没 return

y = good_add(3, 5)            # 屏幕上什么都不显示
print(y * 2)                  # 16 —— 结果能接着算

一个函数没有 return,它返回的就是 None(6-3 学过的"空")。

return 还有第二个作用:立刻结束函数(和循环里的 break 有点像):

def check_age(age):
    if age < 0:
        return "年龄不能是负数"     # 直接结束,后面的代码不执行
    if age < 18:
        return "未成年"
    return "成年"

print(check_age(-5))      # 年龄不能是负数
print(check_age(10))      # 未成年
print(check_age(25))      # 成年

1.5 返回多个值(Python 的特产)

别的语言要返回两个值很麻烦,Python 直接返回:

def divide(a, b):
    """返回 (商, 余数)"""
    return a // b, a % b

shang, yu = divide(10, 3)      # 直接拆成两个变量接住
print(shang, yu)               # 3 1
📝 这背后是"元组"

return a // b, a % b 实际返回的是一个元组(可以理解为"打包在一起的一小组值")。 左边用两个变量接,叫解包——这是 Python 很常用的写法,本节 Demo 会用到它。6-6 会正式讲元组。

1.6 默认参数:可以省略的"标配"

def connect(host, port=22):
    print(f"连接 {host},端口 {port}")

connect("192.168.1.10")           # 用默认端口 22
connect("192.168.1.10", 5900)     # 指定端口 5900
⚠️ 默认参数必须放在后面
def connect(port=22, host):       # ❌ SyntaxError: non-default argument follows default argument

因为调用时 Python 是从左往右对号的,带默认值的参数如果放前面,后面的参数就永远对不上号。

还有个更好用的形式——按名字传参:

connect(host="192.168.1.10", port=5900)      # 名字写清楚,顺序无所谓

参数多的时候强烈建议这么写(读代码的人一眼知道哪个是哪个,不容易传错顺序)。

1.7 作用域:函数里的变量是"私有的"

count = 0                     # 全局变量(函数外面)

def add_one():
    count = 1                 # 这是新的局部变量!不是外面那个
    print(f"函数里看到的是 {count}")

add_one()                     # 函数里看到的是 1
print(f"函数外还是 {count}")   # 函数外还是 0

关键理解:函数内部新赋值的变量,默认是"局部"的——函数执行完就消失了,外面的同名变量动都没动。

💡 想改外面的变量,必须声明 `global`
count = 0

def add_one():
    global count              # 声明:我要改的是外面那个
    count += 1

add_one()
add_one()
print(count)                  # 2

但更推荐的做法是"别用 global"——让函数用参数收数据、用 return 交结果,这样函数是"干净的",好懂也不容易出乱子。 global 是那种"知道有它就行,尽量别用"的东西。

1.8 文档字符串:给函数写一句"说明"

def circle_area(r):
    """算圆的半径 r 对应的面积(r 为负数时返回 None)"""
    if r < 0:
        return None
    return math.pi * r * r

放在 def 下一行的三引号字符串,就是文档字符串(docstring)。 调用 help(circle_area) 就能看到它。

为什么要写:呼应 6-2 那个"报错里 Python 会告诉你 line N"——Python 对新手友好的一面,就是它处处留说明。你写的函数也留一句,过一个月回来还能立刻看懂。

二、模块:用别人写好的函数

2.1 模块是什么

一个 .py 文件就是一个模块。 你自己写的 hello.py 也是模块。

Python 自带一大批模块,叫标准库——装了 Python 就有,不用另外下载。

2.2 import 的三种写法

import random                    # ① 最常用:用的时候写 random.randint(...)
import random as rd              # ② 起个短别名:用的时候写 rd.randint(...)
from random import randint       # ③ 只拿一个函数出来:直接用 randint(...)
写法 调用时的样子 什么时候用
import random random.randint(1, 10) 默认就用它——名字带在前面,一眼知道是谁家的
import ... as rd rd.randint(1, 10) 名字太长时(如 import RPi.GPIO as GPIO)
from ... import ... randint(1, 10) 只用一两个函数时(如 from time import sleep)
⚠️ 第三种要慎用(有个真实的坑)

from ... import ... 会把名字直接搬进你的命名空间,可能和你的变量撞名。 最典型的是:from math import *(把所有东西都搬进来)——它里面有个 pow、有个 e,很容易和你自己的变量撞上,撞了之后行为会变得很莫名其妙。 建议:一律写成 import 模块名 或 import 模块名 as 短名,把来源标清楚。

2.3 三个马上能用的标准库

import random
import math
from time import sleep

print(random.randint(1, 100))        # 1~100 的随机整数(含两端)
print(random.choice(["红", "绿", "蓝"]))  # 从一串里随机挑一个
print(math.sqrt(16))                 # 平方根 → 4.0
print(math.pi)                       # 圆周率 3.141592653589793
print(round(math.pi, 2))             # 四舍五入到两位 → 3.14

print("倒数 3 秒…")
for i in range(3):
    print(i + 1)
    sleep(1)                          # 停 1 秒(这就是 3-3 灯语实验里的那个 sleep)
print("开始!")
💡 `sleep()` 是树莓派的"心跳"

你以后写的所有硬件控制代码里都会有它——让 LED 闪、让蜂鸣器响、让传感器隔一段读一次,全靠 sleep() 控制节奏。 单位是秒,可以是小数:sleep(0.5) 就是半秒。

三、装第三方库:本节最大的坑(老教程在这儿全军覆没)

标准库解决不了的事(比如"发一个 HTTP 请求"),就需要第三方库。

网上几乎所有的中文教程都这么教你:

pip3 install requests          # ⚠️ 现在的树莓派上,这行会直接失败

你实际会看到:

error: externally-managed-environment

× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to install.
    ...
⚠️ 这不是报错,是系统在保护你

这个机制叫 PEP 668,从 Bookworm 起生效,Trixie 依然(对应本课 2-2 讲的系统换代)。

原因:以前 pip install 会往系统 Python 目录里塞包,和 apt 装的包混在一起——经常把系统工具搞坏(树莓派上有的系统程序自己就依赖 Python)。 所以现在系统默认锁死了"直接往全局装包"这条路。

一句话记:pip install 直接装 = 已经不允许了。 看到 externally-managed-environment 别慌,这不是你错了,是玩法变了。

现在正确的两条路:

3.1 路线 A:用 apt 装(最推荐,树莓派上首选)

很多常用库已经被 Debian 打包好了,直接用系统包管理器装:

sudo apt update
sudo apt install python3-requests
⚠️ 这里有个"看起来没装上"的错觉

apt 的包名带 python3- 前缀,但 Python 里 import 时不带:

装的时候 用的时候
sudo apt install python3-requests import requests
sudo apt install python3-opencv import cv2
sudo apt install python3-gpiozero from gpiozero import LED

装完 import 报 ModuleNotFoundError?先回头核对包名写对没有。 这是本节第二个高频坑。

验证装好了:

python3 -c "import requests; print(requests.__version__)"

能打印出版本号就成功。

3.2 路线 B:虚拟环境(venv)——项目专用的小隔间

如果某个库 apt 里没有(或者版本太旧),就得用 venv:给这个项目单独开一个"小 Python 环境",装什么都只影响这里,不碰系统。

# ① 进你的项目目录
cd ~/projects

# ② 建一个虚拟环境(名字随便,习惯叫 venv 或 .venv)
python3 -m venv venv

# ③ 激活它(注意前面那个点,还有中间的空格)
source venv/bin/activate

激活成功的标志:命令提示符前面多了一对括号,像这样:

(venv) 你的用户名@树莓派:~/projects$
# ④ 现在可以正常 pip install 了
pip install requests

# ⑤ 用完退出
deactivate
⚠️ 三个 venv 的关键细节

① 每次新开终端都要重新激活(source venv/bin/activate)。不激活就用 pip install,还是会被 PEP 668 拒绝——这也是九成人"明明建了环境还报错"的原因。

② 如果报错说缺 ensurepip,先补一个包:

sudo apt install python3-venv

③ ⚠️ 如果你的项目要用 GPIO(这是本课后面一定会遇到的),建 venv 时必须加一个参数:

python3 -m venv --system-site-packages venv

为什么:gpiozero 那些库是 apt 装在系统里的,普通 venv 默认看不见系统包——不加这个参数,你在 venv 里 import gpiozero 会失败,而且报错信息还看不出原因。 --system-site-packages 的意思是"让这个虚拟环境也能看见系统里的库"。以后做硬件项目,加它就对了。

3.3 三条路怎么选(一句话版)

情况 用什么
apt 里有这个库(大多数常用库) sudo apt install python3-xxx ← 树莓派上首选
apt 里没有 / 版本太旧 / 想隔离 venv + pip install
只是临时试一下(不推荐) pip install --break-system-packages xxx ← 能绕开保护,但可能搞坏系统,别用

四、动手 Demo:把猜数字游戏拆成函数

上一节那个猜数字游戏,现在用函数重写一遍——你会立刻感受到"代码变清楚了"。

cd ~/projects
nano guess2.py

内容:

import random


def make_secret():
    """随机生成一个 1~100 的整数"""
    return random.randint(1, 100)


def check(guess, secret):
    """比较猜测与答案,返回 (是否猜对, 提示文字)"""
    if guess < secret:
        return False, "小了,再大一点。"
    elif guess > secret:
        return False, "大了,再小一点。"
    else:
        return True, "猜对了!"


def play():
    """一整局游戏"""
    secret = make_secret()
    tries = 0
    print("我想了一个 1 到 100 之间的整数,你来猜。")

    while True:
        guess = int(input("请输入你的猜测:"))
        tries += 1
        hit, msg = check(guess, secret)      # 一次接住两个返回值
        print(msg)
        if hit:
            print(f"答案就是 {secret},你一共猜了 {tries} 次。")
            break


play()

跑:

python3 guess2.py

看到什么算成功:和 6-4 那个版本几乎一样。唯一的差别是最后两行分开了——新版先打印 猜对了!(这是 check() 返回的提示文字),再打印成绩(这句由 play() 打印)。

我想了一个 1 到 100 之间的整数,你来猜。
请输入你的猜测:50
小了,再大一点。
请输入你的猜测:80
大了,再小一点。
请输入你的猜测:65
猜对了!
答案就是 65,你一共猜了 3 次。

读结果 —— 对比一下两版有什么不同:

6-4 的版本 本节版本
结构 一大坨,从上往下 三个小函数,各司其职
想看出题逻辑 得在中间找 看 make_secret() 就行
想判断逻辑 混在主循环里 check() 独立,还能单独测试
想再来一局 得重启程序 再调一次 play() 就行
💡 亲手验证"函数可以复用"

把最后一行 play() 改成:

play()
print("再来一局!")
play()

再跑一次——不需要改任何内部代码,游戏就能玩两轮。 这就是函数最实际的价值:"能反复用"不是理论,是你现在就能感受到的东西。

五、30 秒实验

实验一:print 和 return 的差别,亲手验一遍

>>> def bad(a, b):
...     print(a + b)
...
>>> def good(a, b):
...     return a + b
...
>>> x = bad(3, 5)
8
>>> print(x)
None                          # ← 只能看,拿不走
>>> y = good(3, 5)            # 屏幕上什么都不显示
>>> print(y * 2)
16                            # ← 能接着算

亲自看见那个 None,比读十遍解释都管用。

实验二:感受"局部变量"

>>> x = 100
>>> def change():
...     x = 1
...     print(f"函数里:{x}")
...
>>> change()
函数里:1
>>> print(f"函数外:{x}")
函数外:100                    # ← 外面这个没被改

实验三:import 三种写法跑一遍

>>> import random
>>> random.randint(1, 100)
42
>>> import random as rd
>>> rd.randint(1, 100)
87
>>> from random import randint
>>> randint(1, 100)
13

三种写法都能拿到随机数,只是"叫它"的方式不同。

实验四(本节的重量级实验):亲手撞一次 PEP 668 的墙

先确认自己没在虚拟环境里(提示符前面没有 (venv)),然后:

pip3 install requests

你会看到那个 externally-managed-environment 报错。 恭喜——你亲眼见到了"老教程失效"的现场。

然后走正确路线:

sudo apt update
sudo apt install python3-requests
python3 -c "import requests; print('装上啦,版本', requests.__version__)"

说明一下:requests 在 Debian 仓库里是有的,所以这条路能走通。如果换成 apt 里没有的库,就得上 venv(见 3.2)。

六、常见误区

  1. "print 就是 return,反正都出结果了" —— 完全不同。print 给人看(拿不走,函数返回 None),return 给程序用(能接着算)。 这是新手最常混淆的一对。
  2. "函数名后面加不加括号都一样" —— 不一样。func 只是"提到它",func() 才是"用"它。 少了括号不报错,但什么都不发生——这是最阴的一类 bug。
  3. "pip install 装库天经地义" —— 在现在的树莓派上会直接被拒(PEP 668)。首选 sudo apt install python3-xxx;要 pip 就先进 venv。
  4. "apt 装了 python3-requests,代码里就该 import python3-requests" —— 错。包名带 python3- 前缀,import 时去掉:装 python3-requests,写 import requests。
  5. "建了 venv 就一劳永逸了" —— 每次新开终端都要重新 source venv/bin/activate。不激活就 pip install,照样被 PEP 668 拒绝。
  6. "from math import * 挺省事的" —— 隐患大。它会把几十个名字搬进你的代码里(pow、e、pi…),很容易和你自己的变量撞名,撞了之后行为莫名其妙。写全 import math,用 math.pi 更清楚。
  7. "函数里改全局变量直接改就行" —— 直接赋值只会新建一个局部变量,外面那个纹丝不动(而且不报错,最难查)。真要改就声明 global;更好的做法是用参数和 return 传。
自测三问(点开对答案) 1. `print(x)` 和 `return x` 最本质的区别是什么?一个没有 `return` 的函数,返回什么? 2. 现在的树莓派上 `pip3 install requests` 为什么会报 `externally-managed-environment`?正确的两条路是什么? 3. 做 GPIO 项目建虚拟环境时,为什么必须加 `--system-site-packages`? **参考答案** 1. **`print` 是"给人看"**——把值显示在屏幕上,但**程序拿不到它**;**`return` 是"给程序用"**——把值交回调用处,**能接着参与运算**。一个函数没有 `return`(或者 `return` 后面啥都没写),**返回的是 `None`**。 2. 因为 **PEP 668**:系统锁死了"往全局 Python 里装包"这条路,防止 pip 装的包和 apt 装的包打架、把系统工具搞坏(**Bookworm 起生效,Trixie 依然**)。两条正路:**① `sudo apt install python3-xxx`**(Debian 已经打包好的库,树莓派上首选);**② 建 venv,`source venv/bin/activate` 激活之后再 `pip install`。** 3. 因为 **`gpiozero` 这些硬件库是 apt 装在系统 Python 里的**,而**虚拟环境默认"看不见"系统包**——不加这个参数,`import gpiozero` 会失败,而且报错信息看不出真正原因。**`--system-site-packages` 就是让虚拟环境也能用系统里的库。**

相关笔记