第8章 8.4 pathlib 与 typing

🎯 开场 3 分钟:为什么要学这个?

上一章我们学会了 functools 这个函数工具箱,用 partialwraps 让函数复用更优雅。但你有没有遇到过这种情况——写了一个数据处理脚本,过两个月再打开,发现自己都看不懂每个函数该传什么参数了?

这就是今天要解决的痛点:代码的可读性和可维护性

举个例子,你看到 process_file(path, mode, encoding) 这样的函数签名,知道它要干嘛吗?但如果写成 process_file(path: Path, mode: str, encoding: str = "utf-8") -> dict,是不是一目了然?

这一章我们学两个工具:
- pathlib:让文件路径操作像搭积木一样直观
- typing:给代码装上"说明书",让参数和返回值一目了然

学完之后,你写的代码不仅能跑,还能让同事(包括未来的自己)看得懂、愿意看。


🧱 基础 25 分钟:核心概念

pathlib:告别 os.path 的字符串操作

什么是 pathlib?

想象你要去图书馆找一本书。传统方式(os.path)像是在说"沿着这条路走,经过第三个路口左转,再走50米"——你得自己记住路线。而 pathlib 像是直接说"去文学区B书架第三层"——它把路径变成了一个对象,你能问它"你是文件还是文件夹?""你的父文件夹是谁?"

简单说:pathlib 把路径字符串封装成了对象,让你可以用面向对象的方式操作文件和文件夹

为什么要用 pathlib?

传统 os.path pathlib
os.path.join("a", "b", "c") Path("a") / "b" / "c"
os.path.exists(p) p.exists()
os.path.isfile(p) p.is_file()
os.path.splitext(p)[1] p.suffix

看到了吗?pathlib 把所有操作都变成了对象的方法和属性,不再需要记一堆独立函数。

怎么用?

先看最基础的创建 Path 对象:

from pathlib import Path

# 用字符串创建路径
p = Path(".")
print(p)  # .

# 绝对路径
p = Path("/Users/apple/workspace")
print(p)  # /Users/apple/workspace

# 拼接路径 - 用 / 运算符,就像搭积木
p = Path("/Users/apple") / "Documents" / "project.py"
print(p)  # /Users/apple/Documents/project.py

这里 Path("/Users/apple") / "Documents" 返回的仍然是一个 Path 对象,所以可以继续用 / 拼接下去。

路径的"自我介绍"方法

创建好 Path 对象后,你可以问它各种问题:

from pathlib import Path

p = Path("example.txt")

# 这个路径存在吗?
print(p.exists())  # False

# 是文件还是文件夹?
print(p.is_file())  # False
print(p.is_dir())   # False

# 文件名、扩展名、后缀
print(p.name)      # example.txt
print(p.stem)      # example
print(p.suffix)    # .txt
print(p.parent)    # .
print(p.parents)   # [PosixPath('.'), PosixPath('/')]

# 创建文件测试一下
p.write_text("Hello, pathlib!")
print(p.exists())  # True
print(p.read_text())  # Hello, pathlib!

配图1 - 配图1

遍历文件夹

from pathlib import Path

# 列出当前目录下的所有 .py 文件
for py_file in Path(".").glob("*.py"):
print(py_file.name)

# 递归查找所有文件(包括子文件夹)
for item in Path(".").rglob("*"):
if item.is_file():
    print(f"文件: {item.name}")
else:
    print(f"文件夹: {item.name}/")

创建和删除

from pathlib import Path

# 创建文件夹
(Path("demo_folder")).mkdir(exist_ok=True)

# 创建多层嵌套文件夹
(Path("demo_folder/sub_folder/deep")).mkdir(parents=True, exist_ok=True)

# 写文件
(Path("demo_folder") / "test.txt").write_text("内容")

# 删除文件
(Path("demo_folder") / "test.txt").unlink()

# 删除空文件夹
(Path("demo_folder")).rmdir()

注意rmdir() 只删除空文件夹。如果要删除文件夹及其内容,需要用 shutil.rmtree()


typing:给代码装上说明书

什么是 typing?

你有没有过这种经历——看别人的代码,def process(data): 看到这样的函数,不知道 data 到底是什么类型,返回什么。

typing 就是来解决这个问题的。它让你在代码里明确标注:这个参数是什么类型,这个函数返回什么类型

类比一下:typing 就像是给菜谱加上了配料表。原来的写法是"加入调料",加了 typing 之后是"加入调料(盐:半勺 | 糖:一勺 | 类型:调味料实例)"。

为什么要用 typing?

  1. IDE 提示:写代码时自动补全更准确
  2. 减少 bug:类型不对时,IDE 会警告你
  3. 文档作用:代码本身就是文档,不用额外写注释解释参数类型
  4. 团队协作:别人读你代码时,一眼就知道该传什么

怎么用?

基础类型注解

def greet(name: str, age: int) -> str:
return f"Hello, {name}, you are {age} years old."

result: str = greet("小明", 10)
print(result)  # Hello, 小明, you are 10 years old.

函数定义中,name: str 表示参数 name 应该是 str 类型,age: int 同理,-> str 表示返回 str 类型。

常用类型

from typing import List, Dict, Tuple, Optional, Union

# 列表类型
def sum_numbers(numbers: List[int]) -> int:
return sum(numbers)

# 字典类型
def get_scores(students: Dict[str, int], name: str) -> int:
return students.get(name, 0)

# 可选类型(可以是这个类型,也可以是 None)
def find_user(user_id: int) -> Optional[str]:
users = {1: "Alice", 2: "Bob"}
return users.get(user_id)

# 联合类型(可以是多种类型之一)
def parse_input(value: Union[str, int]) -> str:
return str(value)

print(sum_numbers([1, 2, 3]))           # 6
print(get_scores({"Alice": 95, "Bob": 88}, "Alice"))  # 95
print(find_user(3))                      # None
print(parse_input(123))                  # 123

Tuple 和 Any

from typing import Tuple, Any

# 元组类型 - 固定长度,每个位置类型可能不同
def get_point() -> Tuple[int, int]:
return (10, 20)

# 任意类型 - 当你不确定类型时
def debug_print(value: Any) -> None:
print(f"Debug: {value}")

类型别名

当类型很复杂时,可以给它起个简短的外号:

from typing import List, Dict

# 给复杂类型起别名
UserId = int
UserScore = Dict[str, int]
ScoreList = List[int]

def get_user_score(user_id: UserId, scores: UserScore) -> ScoreList:
"""获取某个用户的所有分数"""
user_name = {1: "Alice", 2: "Bob"}.get(user_id, "Unknown")
return [score for name, score in scores.items() if name == user_name]

scores_db: UserScore = {"Alice": 95, "Bob": 88, "Carol": 92}
print(get_user_score(1, scores_db))  # [95]

配图2 - 配图2


🔥 实战 35 分钟:3 个递进的小项目

项目 1:文件整理小助手(5 分钟)

场景:你下载了一堆文件,桌面乱糟糟的,想按扩展名分类整理。

from pathlib import Path
from typing import Dict, List

def organize_files_by_extension(source_folder: Path, target_folder: Path) -> Dict[str, List[str]]:
"""
把文件夹里的文件按扩展名分类整理到目标文件夹
"""
# 确保目标文件夹存在
target_folder.mkdir(parents=True, exist_ok=True)

# 记录整理结果
organized: Dict[str, List[str]] = {}

# 遍历源文件夹里的所有文件
for file_path in source_folder.glob("*"):
    if file_path.is_file():
        # 获取扩展名(比如 .txt)
        ext = file_path.suffix.lower() or "no_extension"
        ext_folder = target_folder / ext[1:]  # 去掉前面的点
        ext_folder.mkdir(exist_ok=True)

        # 移动文件
        new_path = ext_folder / file_path.name
        file_path.rename(new_path)

        # 记录
        if ext not in organized:
            organized[ext] = []
        organized[ext].append(file_path.name)

return organized

# 测试
source = Path("downloads")
target = Path("organized")

# 先创建测试文件
source.mkdir(exist_ok=True)
(source / "report.txt").write_text("文本文件")
(source / "photo.jpg").write_text("图片文件")
(source / "data.csv").write_text("数据文件")

# 运行整理
result = organize_files_by_extension(source, target)

# 打印结果
print("整理完成!")
for ext, files in result.items():
print(f"  {ext}: {files}")

# 清理测试文件夹
import shutil
shutil.rmtree(source)
shutil.rmtree(target)

预期输出

整理完成!
.txt: ['report.txt']
.jpg: ['photo.jpg']
.csv: ['data.csv']

解释:这个脚本找到所有文件,按扩展名创建文件夹,然后把文件移动过去。用的是 pathlib 的面向对象操作,比传统的 os.path 好读多了。


项目 2:日志分析工具(15 分钟)

场景:你有一个网站日志文件,想统计每个 URL 被访问了多少次,按访问次数排序。

假设日志格式是这样的(access.log):

192.168.1.1 - - [01/Jan/2024:10:00:00 +0000] "GET /home HTTP/1.1" 200
192.168.1.2 - - [01/Jan/2024:10:01:00 +0000] "GET /api/users HTTP/1.1" 200
192.168.1.1 - - [01/Jan/2024:10:02:00 +0000] "GET /home HTTP/1.1" 200
192.168.1.3 - - [01/Jan/2024:10:03:00 +0000] "POST /api/users HTTP/1.1" 201
192.168.1.1 - - [01/Jan/2024:10:04:00 +0000] "GET /about HTTP/1.1" 200
from pathlib import Path
from typing import Dict, List, Tuple
import re

def parse_log_file(log_path: Path) -> List[Tuple[str, int]]:
"""
解析日志文件,统计每个 URL 的访问次数
"""
url_counts: Dict[str, int] = {}

# 读取日志文件
content = log_path.read_text(encoding="utf-8")
lines = content.strip().split("\n")

# 用正则提取 URL
pattern = re.compile(r'"(GET|POST|PUT|DELETE)\s+(\S+)\s+HTTP"')

for line in lines:
    match = pattern.search(line)
    if match:
        url = match.group(2)
        url_counts[url] = url_counts.get(url, 0) + 1

# 按访问次数排序
sorted_urls = sorted(url_counts.items(), key=lambda x: x[1], reverse=True)

return sorted_urls

def save_report(report_path: Path, data: List[Tuple[str, int]]) -> None:
"""

把统计结果保存到文件
"""
lines = ["URL,访问次数"]
for url, count in data:
    lines.append(f"{url},{count}")
report_path.write_text("\n".join(lines), encoding="utf-8")

def main() -> None:
"""主函数"""
# 准备测试日志
log_file = Path("access.log")
log_content = """192.168.1.1 - - [01/Jan/2024:10:00:00 +0000] "GET /home HTTP/1.1" 200
192.168.1.2 - - [01/Jan/2024:10:01:00 +0000] "GET /api/users HTTP/1.1" 200
192.168.1.1 - - [01/Jan/2024:10:02:00 +0000] "GET /home HTTP/1.1" 200
192.168.1.3 - - [01/Jan/2024:10:03:00 +0000] "POST /api/users HTTP/1.1" 201
192.168.1.1 - - [01/Jan/2024:10:04:00 +0000] "GET /about HTTP/1.1" 200
192.168.1.2 - - [01/Jan/2024:10:05:00 +0000] "GET /home HTTP/1.1" 200
192.168.1.4 - - [01/Jan/2024:10:06:00 +0000] "GET /api/users HTTP/1.1" 200"""
log_file.write_text(log_content, encoding="utf-8")

# 分析日志
print("正在分析日志...")
stats = parse_log_file(log_file)

# 打印结果
print("\n访问统计:")
print("-" * 40)
for url, count in stats:
    print(f"  {url:20s} : {count:4d} 次")
print("-" * 40)

# 保存报告
report_file = Path("report.csv")
save_report(report_file, stats)
print(f"\n报告已保存到: {report_file}")

# 清理
log_file.unlink()
report_file.unlink()

if __name__ == "__main__":
main()

预期输出

正在分析日志...

访问统计:
----------------------------------------
/home                :    4 次
/api/users           :    3 次
/about                :    1 次
----------------------------------------

报告已保存到: report.csv

解释:这个工具读取日志文件,用正则提取 URL,统计每个 URL 的访问次数,最后排序输出并保存报告。用了 pathlib 的 Path 对象和完整的类型注解,代码读起来像在读说明书。


项目 3:个人待办清单管理器(15 分钟)

场景:做一个命令行待办清单工具,可以添加、查看、完成、删除任务,数据存在 JSON 文件里。

from pathlib import Path
from typing import List, Optional
import json
from datetime import datetime

class TodoItem:
"""单个待办事项"""
def __init__(self, id: int, title: str, completed: bool = False, created_at: str = ""):
    self.id = id
    self.title = title
    self.completed = completed
    self.created_at = created_at or datetime.now().isoformat()

def to_dict(self) -> dict:
    return {
        "id": self.id,
        "title": self.title,
        "completed": self.completed,
        "created_at": self.created_at
    }

@staticmethod
def from_dict(data: dict) -> "TodoItem":
    return TodoItem(
        id=data["id"],
        title=data["title"],
        completed=data.get("completed", False),
        created_at=data.get("created_at", "")
    )

class TodoList:
"""待办清单管理器"""
def __init__(self, storage_path: Path):
    self.storage_path = storage_path
    self.items: List[TodoItem] = []
    self._load()

def _load(self) -> None:
    """从文件加载数据"""
    if self.storage_path.exists():
        data = json.loads(self.storage_path.read_text(encoding="utf-8"))
        self.items = [TodoItem.from_dict(item) for item in data]
    else:
        self.items = []

def _save(self) -> None:
    """保存数据到文件"""
    data = [item.to_dict() for item in self.items]
    self.storage_path.write_text(
        json.dumps(data, ensure_ascii=False, indent=2),
        encoding="utf-8"
    )

def add(self, title: str) -> TodoItem:
    """添加新任务"""
    new_id = max([item.id for item in self.items], default=0) + 1
    item = TodoItem(id=new_id, title=title)
    self.items.append(item)
    self._save()
    return item

def list_all(self) -> List[TodoItem]:
    """列出所有任务"""
    return self.items

def complete(self, task_id: int) -> Optional[TodoItem]:
    """标记任务为完成"""
    for item in self.items:
        if item.id == task_id:
            item.completed = True
            self._save()
            return item
    return None

def delete(self, task_id: int) -> bool:
    """删除任务"""
    for i, item in enumerate(self.items):
        if item.id == task_id:
            self.items.pop(i)
            self._save()
            return True
    return False

def main():
"""演示待办清单功能"""
storage = Path("todos.json")
todo_list = TodoList(storage)

# 添加任务
print("添加任务...")
todo_list.add("学习 pathlib")
todo_list.add("学习 typing")
todo_list.add("写实战项目")

# 列出所有
print("\n当前任务:")
for item in todo_list.list_all():
    status = "✓" if item.completed else "○"
    print(f"  [{status}] {item.id}: {item.title}")

# 完成一个
print("\n完成第二个任务...")
todo_list.complete(2)

# 再列出
print("\n更新后:")
for item in todo_list.list_all():
    status = "✓" if item.completed else "○"
    print(f"  [{status}] {item.id}: {item.title}")

# 删除第一个
print("\n删除第一个任务...")
todo_list.delete(1)

print("\n最终列表:")
for item in todo_list.list_all():
    status = "✓" if item.completed else "○"
    print(f"  [{status}] {item.id}: {item.title}")

# 清理测试文件
storage.unlink()

if __name__ == "__main__":
main()

预期输出

添加任务...

当前任务:
[○] 1: 学习 pathlib
[○] 2: 学习 typing
[○] 3: 写实战项目

完成第二个任务...

更新后:
[○] 1: 学习 pathlib
[✓] 2: 学习 typing
[○] 3: 写实战项目

删除第一个任务...

最终列表:
[✓] 2: 学习 typing
[○] 3: 写实战项目

解释:这个待办清单工具用 pathlib 存储数据到 JSON 文件,用 typing 注解了每个方法的参数和返回值。两个类 TodoItemTodoList 职责清晰,代码读起来就像是完整的规格说明书。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:pathlib 的 / 运算符只能拼接字符串

from pathlib import Path

# 正确
p = Path("a") / "b" / "c"
print(p)  # a/b/c

# 错误!/ 运算符两边类型要匹配
# p = Path("a") / 123  # TypeError!
# 如果要拼接数字,先转字符串
number = 123
p = Path("folder") / str(number)

坑 2:Path 对象和字符串不是一回事

from pathlib import Path

p = Path("test.txt")

# 不要混淆
print(p / "sub")     # Path 对象拼接字符串 → test.txt/sub
print(str(p) + "2")  # 字符串拼接 → test.txt2

# 判断存在用 .exists(),不是 in
# 错误:if "test.txt" in Path("."):  # 不起作用
# 正确:
if p.exists():
print("文件存在")

坑 3:Windows 路径的坑

from pathlib import Path

# Windows 系统下 Path("C:/") 和 Path("C:\\") 等价
# 但写代码时建议用 /,Python 会自动处理

p = Path("C:/Users/Admin/Documents")
# 等价于
p = Path(r"C:\Users\Admin\Documents")  # raw string

# 获取纯文件名,不要用字符串切片
filename = "report.txt"
# 错误:base = filename[:-4]  # 假设 .txt 是 4 个字符
# 正确:
base = Path(filename).stem
print(base)  # report

坑 4:typing 的 Optional vs Union

from typing import Optional, Union

# Optional[X] 其实是 Union[X, None] 的简写
def foo1(x: Optional[str]) -> None:
pass

def foo2(x: Union[str, None]) -> None:
pass

# 两者效果一样,但 Optional 更简洁

坑 5:类型注解不影响运行时

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

# 运行时完全不检查类型!
result = greet(123)  # 不会报错,只是返回 "Hello, 123"
print(result)  # Hello, 123

# 如果需要运行时检查,用 isinstance 或者第三方库如 pydantic

性能小贴士:Path.exists() 不要在循环里重复调用

from pathlib import Path

# 慢:每次都检查文件系统
files = [Path(f"file{i}.txt") for i in range(1000)]
existing = [f for f in files if f.exists()]  # 1000 次 IO 操作

# 快:先收集,再统一检查
files = [Path(f"file{i}.txt") for i in range(1000)]
existing = [f for f in files if f.is_file()]  # 也是 1000 次,但 is_file() 在某些场景更快

# 更快:批量操作
# 如果你需要频繁检查,可以用 os.path.exists() 批量检查

调试技巧:用 Path.write_text 快速输出调试信息

from pathlib import Path

# 调试时快速打印复杂对象
debug_data = {"key": "value", "nested": {"a": 1}}
Path("debug.txt").write_text(str(debug_data))

# 或者打印到标准错误
import sys
print("调试信息", file=sys.stderr)

✏️ 练习题 + 作业题

练习题(10 分钟)

练习 1(2 分钟):Path 对象基础操作
- 输入:创建一个 Path 对象表示 /home/user/documents/report.txt
- 预期输出:打印出文件名 report.txt 和扩展名 .txt
- 提示:用 Path(...).namePath(...).suffix

from pathlib import Path

p = Path("/home/user/documents/report.txt")
print(p.name)
print(p.suffix)

练习 2(2 分钟):给函数加类型注解
- 输入:把下面的函数加上完整的类型注解

def process_data(data, options):
return {k: v * 2 for k, v in data.items() if options.get("double")}
  • 预期输出:函数签名变成带类型注解的版本
  • 提示:返回值是 Dictoptions 可以用 Dict[str, Any]
from typing import Dict, Any

def process_data(data: Dict[str, int], options: Dict[str, Any]) -> Dict[str, int]:
return {k: v * 2 for k, v in data.items() if options.get("double")}

练习 3(2 分钟):遍历特定类型文件
- 输入:在当前目录找所有 .json 文件并打印名字
- 预期输出:列出所有 .json 文件
- 提示:用 Path(".").glob("*.json")

from pathlib import Path

for f in Path(".").glob("*.json"):
print(f.name)

练习 4(2 分钟):Optional 类型的使用
- 输入:写一个函数,接受用户 ID,返回用户名(找不到返回 None)
- 预期输出:get_user(1) 返回 "Alice"get_user(999) 返回 None
- 提示:用 Optional[str]

from typing import Optional

def get_user(user_id: int) -> Optional[str]:
users = {1: "Alice", 2: "Bob"}
return users.get(user_id)

print(get_user(1))
print(get_user(999))

练习 5(2 分钟):修复类型错误
- 输入:下面代码会报错,找出并修复

from pathlib import Path
p = Path("test.txt")
result = p / 123
  • 预期输出:能正确拼接 Path("test.txt") / "123"
  • 提示:/ 运算符需要两边都是 Path 或 str
from pathlib import Path
p = Path("test.txt")
result = p / str(123)  # 或 p / "123"
print(result)

作业题(30 分钟-2 小时)

做一个「文件夹大小统计工具」

需求描述
做一个命令行工具,统计指定文件夹的大小(包含所有子文件夹),并生成按大小排序的文件/文件夹列表。

功能点
1. 接收一个文件夹路径作为参数(默认当前目录)
2. 统计该文件夹下每个子文件夹的大小
3. 统计该文件夹下每个文件的大小
4. 按大小降序排列,输出前 10 个
5. 用 pathlib 处理路径
6. 用 typing 注解所有函数

加分项
1. 支持 --top N 参数显示前 N 个(默认 10)
2. 显示文件数量统计(多少个文件,多少个文件夹)
3. 生成 CSV 报告文件

验收标准
- 能跑起来
- 给定一个真实文件夹,能正确统计大小
- 代码有注释,类型注解完整

提交方式:评论区贴代码或 GitHub 链接


📚 总结 + 资源

一句话总结

这一章我们学了:
1. pathlib 让文件路径操作变成面向对象,代码更易读
2. typing 给代码加上类型注解,让函数签名一目了然
3. 两者结合 能写出既好跑又好懂的代码

延伸学习资源

  1. Python 官方文档 - pathlib
  2. Python 官方文档 - typing
  3. 《Python 编程:从入门到实践》- 第 10 章文件与异常

你在写项目时更看重可读性还是性能?为什么?用 pathlib 和 typing 写过什么有意思的东西吗?评论区聊聊,老粉优先回复!

下一章我们要学的是「列表推导式与生成器表达式」,这是一个能让你用一行代码搞定数据转换的神器,学会之后你的 Python 代码会变得超级简洁。敬请期待!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。