Python 语言基础:函数、模块、文件与异常处理

围绕 Python 从函数到文件与异常的最小闭环:函数定义、类型提示、参数、模块导入、文件读写、with 与 try/except。

一、函数、参数与返回值

函数可以先理解成:把一段可重复使用的功能封装起来,需要时再调用。

函数的基本定义

最小语法骨架:

def 函数名(参数1, 参数2, ...):
    函数体
    return 返回值
  • def 用来定义函数
  • 参数用于接收外部传入的数据
  • return 用于把结果返回给调用方

例如:

def greet(name, greeting="hello"):
    return f"{greeting}, {name}"

message = greet("alex")
print(message)

函数按参数和返回值分类

1. 无参数、无返回值

def greet() -> None:
    print("您好")

greet()

2. 有参数、无返回值

def greet(name: str) -> None:
    print(name + ",您好")

greet("张老师")
greet("赵老师")

3. 有参数、有返回值

def greet(name: str) -> str:
    return name + ",您好"

result = greet("张老师")
print(result)

4. 多返回值

def parse_user() -> tuple[str, int]:
    return "alex", 18

name, age = parse_user()
print(name, age)  # alex 18

二、函数签名里的类型提示

学函数时,最适合顺手把类型提示一起掌握,因为它最常写在函数签名上。

def greet(name: str) -> str:
    return f"Hello, {name}"

def find_user(user_id: int) -> str | None:
    if user_id == 1:
        return "alex"
    return None

def log_message(message: str) -> None:
    print(message)

这里最重要的是:

  • 参数类型写在 : 后面
  • 返回值类型写在 -> 后面
  • 可能为空就写 str | None
  • 没有返回值就写 -> None

容器类型怎么写

如果函数参数或返回值是容器,常见写法是:

scores: list[int] = [90, 85, 92]
user: dict[str, int] = {"id": 1, "age": 18}

def parse_user() -> tuple[str, int]:
    return "alex", 18

也就是把“容器本身的类型”和“里面元素的类型”一起写出来。

写到什么程度就够了

入门阶段优先保证两件事:

  • 给函数参数和返回值加注解
  • 在类型不明显时,再给变量补充注解

如果局部变量一眼就能看懂,通常没必要每个都写:

name = "alex"
total = 100

另外,Any 表示“任意类型”,但它会明显削弱检查效果,能不用就尽量不用。

配合 mypy 做静态检查

类型提示真正的价值,不只是“看起来更清楚”,还在于能配合工具提前发现明显错误。

pip install mypy
mypy script.py

例如下面这段代码,mypy 会在运行前提示参数类型不对:

def add(a: int, b: int) -> int:
    return a + b

add("hello", "world")

三、全局变量和局部变量

变量的作用域,指的是变量在程序里“哪些地方能用、哪些地方不能用”。

  • 在函数外定义的变量,通常叫全局变量
  • 在函数内定义的变量,通常叫局部变量
num = 10
name = "Python"


def fun() -> None:
    local_num = 100
    num = 200
    print("函数内部访问全局变量 name:", name)
    print("函数内部访问局部变量 local_num:", local_num)
    print("函数内部同名局部变量 num:", num)


fun()
print("函数外部访问全局变量 num:", num)
# print(local_num)  # 会报错,函数外部不能直接访问局部变量

这个例子同时说明了三件事:

  • 函数内部通常可以读取外部的全局变量,比如 name
  • 函数内部定义的局部变量,只能在函数内部使用,比如 local_num
  • 如果局部变量和全局变量同名,比如这里的 num,那么函数内部优先使用局部变量,函数外部的全局变量不受影响

如果你确实要在函数内部修改全局变量,需要显式使用 global

count = 0

def increase() -> None:
    global count
    count += 1

increase()
print(count) #1

位置传参与关键字传参

1. 位置参数

def user_info(name: str, age: int, address: str) -> None:
    print(f"我的名字{name},今年{age}岁了,家里住在{address}")


user_info("Tom", 23, "美国纽约")

位置参数要求:实参顺序要和形参顺序保持一致

2. 关键字参数

def user_info(name: str, age: int, address: str) -> None:
    print(f"我的名字{name},今年{age}岁了,家里住在{address}")

user_info(name="Tom", age=23, address="美国纽约")

关键字参数的特点是可读性更强,调用时不容易把顺序写错。

默认参数

默认参数指的是:在定义函数时,先给某个参数准备一个默认值。调用时如果不传,就使用默认值。

def user_info(name: str, age: int, gender: str = "男") -> None:
    print(f"我的名字{name},今年{age}岁了,我的性别为{gender}")

user_info("李林", 25)
user_info("姗儿", 18, "女")

注意: 默认参数一定要写在普通参数后面

可变参数

可变参数也常被叫作“不定长参数”:

  • 在函数定义处,*args / **kwargs 用来收集不固定数量的参数
  • 在函数调用处,* / ** 用来把已有对象拆开后再传给函数

适合的种场景:

  • 调用函数时,参数个数不固定
  • 你事先不知道会传几个位置参数
  • 或者事先不知道会传哪些关键字参数

1. 定义处的 *args**kwargs

  • *args 用来接收额外的位置参数,接收后是元组
  • **kwargs 用来接收额外的关键字参数,接收后是字典
def collect_scores(*scores: int, **metadata: str) -> None:
    print("scores:", scores)
    print("metadata:", metadata)

collect_scores(90, 95, 88, student="tom", course="python")
#scores: (90, 95, 88)
#metadata: {'student': 'tom', 'course': 'python'}

2. 调用处的 ***

当星号出现在函数调用处时,含义就变了:

  • * 会把列表、元组等可迭代对象拆成位置参数
  • ** 会把字典拆成关键字参数
def show_args(*args: object, **kwargs: object) -> None:
    print(args)
    print(kwargs)

values = (1, 2, 4)
items = [11, 15, 23]
data = {"a": 1, "b": 12}

show_args(values, data)
show_args(*values, **data)
show_args(items, data)
show_args(*items, **data)

输出:

((1, 2, 4), {'a': 1, 'b': 12})
{}

(1, 2, 4)
{'a': 1, 'b': 12}

([11, 15, 23], {'a': 1, 'b': 12})
{}

(11, 15, 23)
{'a': 1, 'b': 12}

关键区别是:

show_args(values, data)

这里是把 valuesdata 当作两个普通参数传入。

show_args(*values, **data)

这里才是先“拆开”,再传给函数。

3. 几个容易踩的点

args 不能直接修改

因为 args 本质上是元组,而元组不可变。

def update_first(*args: int) -> None:
    args[0] = 5

update_first(1, 2, 3)
#报错:TypeError: 'tuple' object does not support item assignment

即使你传进去的是列表:

numbers = [1, 2, 3]
update_first(*numbers)

进入函数以后,它依然会被收集成元组。

*dict 传的是字典的键
def show_data(*args: object, **kwargs: object) -> None:
    print(args)
    print(kwargs)

data = {"a": 1, "b": 2, "c": 3}

show_data(*data)
show_data(**data)

输出类似:

('a', 'b', 'c')
{}
()
{'a': 1, 'b': 2, 'c': 3}

也就是说:

  • *data 传入的是字典的键
  • **data 传入的才是键值对

4. 什么时候值得用它们

适合使用 *args / **kwargs 的场景:

  • 写包装函数或装饰器
  • 函数需要兼容不固定参数
  • 需要把参数继续转发给其他函数

例如:

def wrapper(*args: object, **kwargs: object) -> object:
    print("before call")
    return target(*args, **kwargs)

这类场景在装饰器、日志包装和框架代码里都非常常见。

实战建议:

  • 不要把 *args / **kwargs 当成“高级语法炫技”
  • 能写清晰签名时,优先写明确参数
  • 只有在“参数数量不固定”或“需要透传参数”时再使用它们
  • 遇到字典解包时,先确认你到底需要键,还是需要键值对

lambda 表达式

lambda 经常被叫作匿名函数。它适合写那种“逻辑很短、只想临时用一下”的小函数。

最基本的形式可以记成:

lambda 参数列表: 返回值表达式

1. 普通参数

add = lambda a, b: a + b
print(add(1, 2))  # 3

2. 带默认值参数

calc = lambda a, b, c=100: a + b + c
print(calc(10, 20)) # 130

3. 接收可变位置参数

collect = lambda *args: args
print(collect(10, 20, 30)) # (10, 20, 30)

4. 接收可变关键字参数

collect = lambda **kwargs: kwargs
print(collect(name="python", age=20))
# {'name': 'python', 'age': 20}

初学阶段先记住两个特点就够了:

  • lambda 一般只写一个表达式
  • 它会把这个表达式的结果直接作为返回值

四、模块入门:模块、包与导入方式

学到这里,除了会写函数,还要开始知道:一个 .py 文件在项目里到底扮演什么角色。

模块和包怎么区分

  • 一个 .py 文件通常就可以看作一个模块
  • 一个目录如果用来组织多个模块,通常就可以看作一个包

例如:

demo_project/
├── app.py
├── utils.py
└── services/
    ├── __init__.py
    └── user.py

这里可以这样理解:

  • app.pyutils.py 是模块
  • services 是包
  • services/__init__.py 是包的初始化文件,也说明这个目录是一个 Python 包
  • services.user 表示包里的模块

importfrom ... import ...

两种写法的差别主要在可读性与命名空间控制。

如果按课堂入门资料的角度理解,可以先记一句话:模块本质上通常就是一个 Python 文件。

最常见的两种导入方式如下。

方式 1:import 模块名

import random

print(random.randint(1, 10))

这种写法的特点是:调用时要带上模块名,像 random.randint(...)

方式 2:from 模块名 import 名称

from random import randint

print(randint(1, 10))

这种写法的特点是:调用更短,但当前作用域里更容易出现命名冲突。

经验上可以这样理解:

  • import module:更清楚,适合长期维护代码
  • from module import name:调用更短,但更容易和本地变量重名
  • as 可以为模块或对象起别名

__all__ 是做什么的

__all__ 通常出现在模块或包的 __init__.py 里,用来声明“当别人使用 from xxx import * 时,哪些名字应该被导出”。

例如:

# services/__init__.py
from .user import get_user
from .order import create_order

__all__ = ["get_user", "create_order"]

如果其他文件这样写:

from services import *

那么当前作用域里通常只会导入 __all__ 中列出的 get_usercreate_order

可以先把它理解成两点:

  • __all__ 主要影响 from module import * 这种批量导入写法
  • 它常用于包的统一导出,让调用方不必关心内部模块拆分

不过要注意:

  • __all__ 不是“权限控制”,别人仍然可以显式写 from services.user import get_user
  • 日常业务代码里通常不推荐大量使用 import *,因为可读性差,也容易污染命名空间
  • 但在教学示例、包对外暴露公共 API、或框架封装里,__all__ 仍然是一个常见知识点

如果结合前面的目录结构,可以把它理解成:services/__init__.py 不只是包初始化文件,也可以顺手定义这个包“默认希望暴露给外部使用的名字”。

__name__ == "__main__" 是什么

很多脚本都会写这一段:

def main() -> None:
    print("run script")

if __name__ == "__main__":
    main()

它的作用是:

  • 直接运行当前文件时,执行 main()
  • 作为模块被别的文件导入时,不自动执行这段脚本入口逻辑

这在写命令行脚本、临时工具和小型项目入口时非常常见。

五、文件操作与 with

文件打开模式:

  • r:读取文本文件
  • w:写入文本文件,原内容会被覆盖
  • a:向文本文件末尾追加内容
  • rb:以二进制方式读取文件,例如图片、音频、压缩包
  • wb:以二进制方式写入文件,原内容会被覆盖
  • ab:以二进制方式追加内容

使用 with 可以在用完文件后自动关闭它。

这里的 b 表示按二进制数据处理,不按文本处理。

可以先这样记:

  • 文本模式读到的是 str
  • 二进制模式读到的是 bytes
  • 文本模式通常要关心 encoding
  • 二进制模式通常不写 encoding

读写文件

from pathlib import Path

file_path = Path("example.txt")

# 写入文件,w 模式会覆盖原内容。使用 with 语句可以自动管理文件的打开和关闭。
with file_path.open("w", encoding="utf-8") as file:
    file.write("Hello Python\n")

# 读取文件,r 模式为只读。
with file_path.open("r", encoding="utf-8") as file:
    # 逐行读取文件内容,避免一次性加载大文件导致内存占用过高。
    for line in file:
        print(line.strip())

文本替换示例

from pathlib import Path

source = Path("yesterday.txt")
target = Path("yesterday.txt.bak")

find_text = "旧内容"
replace_text = "新内容"

# 同时打开源文件(读)和目标文件(写)
with source.open("r", encoding="utf-8") as src, target.open("w", encoding="utf-8") as dst:
    for line in src:
        # 找到目标片段后再替换,其余内容原样写回。
        dst.write(line.replace(find_text, replace_text))

brbwbab 是什么

当文件内容不是“普通文本”,而是图片、PDF、视频、压缩包这类原始字节数据时,就应该使用二进制模式。

from pathlib import Path

source = Path("avatar.png")
target = Path("avatar-copy.png")

# rb: 以二进制方式读取,返回 bytes
with source.open("rb") as file:
    data = file.read()

# wb: 以二进制方式写入,覆盖原内容
with target.open("wb") as file:
    file.write(data)

# ab: 以二进制方式追加到文件末尾
with target.open("ab") as file:
    file.write(b"\n")

注意:

  • 如果是文本文件,优先使用 rwa
  • 如果是图片、音频、压缩包等非文本文件,优先使用 rbwbab
  • 不要在二进制模式下传 encoding="utf-8"
  • 不要把文本模式和二进制模式混着理解

六、异常处理基础

很多初学者一开始只会“让代码跑起来”,但真实程序还必须处理失败情况。
文件不存在、输入格式错误、网络超时、字典缺键,这些都属于异常处理要面对的问题。

try / except / else / finally

try:
    number = int(input("请输入数字: "))
except ValueError:
    print("输入的不是合法整数")
else:
    print("你输入的是:", number)
finally:
    print("这段代码总会执行")

可以这样理解:

  • try:放可能出错的代码
  • except:出错后怎么处理
  • else:没有异常时执行
  • finally:无论是否异常,最后都会执行

文件读取时为什么经常要配合异常处理

因为文件系统属于外部环境,不完全受程序控制,所以即使代码本身没问题,读文件时也可能失败:

  1. 文件不存在FileNotFoundError
  2. 权限不足PermissionError
  3. 文件被占用、锁定或损坏

如果不做异常处理,程序就可能直接报错退出;使用 try ... except 可以捕获这些外部失败,并给出备用逻辑或更友好的提示。

from pathlib import Path

file_path = Path("config.txt")

try:
    content = file_path.read_text(encoding="utf-8")
except FileNotFoundError:
    print("配置文件不存在")
else:
    print(content)

一个实用原则

  • 预期内的失败,要明确处理
  • 不要用裸 except: 把所有异常一把吞掉
  • 暂时处理不了的异常,可以记录后继续向上抛出

小结

这一篇重点是建立从“会写语句”到“会写函数”再到“会处理模块、文件与异常”的最小能力:

  • 能写带参数、带返回值、带默认值的函数
  • 理解全局变量与局部变量的边界,知道何时用 global
  • 理解 *args / **kwargs 的收集作用,以及调用处 * / ** 的解包作用
  • 区分模块与包,掌握 importfrom ... import 的取舍
  • 熟练使用 with 做文件读写,理解 rwab 这些常见模式
  • 理解 try / except / else / finally 各自的角色,知道外部操作一定会失败

类、对象、__init__()__new__()__call__() 这些面向对象内容,统一放到《Python 进阶特性》继续展开。

把这些基础打牢后,再进入参数解包、路径处理、日志、网络请求这些主题时,就会顺很多。