函数使用(五)

前言

  • 这一篇是 os 的目录操作、os.path 的路径处理,以及文件读写(open 的七种模式、文件指针、编码、with)。
  • 所有示例都在 Python 3.13.9 / Windows 上实跑过,输出原样贴在代码块下面;涉及建目录、删文件的片段都跑在 tempfile.mkdtemp() 开出的临时目录里,不会碰到真实文件。标「POSIX」的是 Linux/macOS 上的通用表现,本机没实测。
  • 原笔记里几处凭印象写的说法已经改掉:不指定 encoding 并不是「系统自动识别」;removedirs 也不是「递归删除目录」;文本模式下 seek 的限制不止「相对当前」这一种。

目录管理(os)

⚠️注意:用之前先 import os。

  • system:执行系统命令

    • 传入一条 shell 命令并执行,返回的是命令的退出码,不是命令的输出;要拿输出得用 os.popen(...).read() 或 subprocess。
    • cls 是 Windows 的清屏命令,类 Unix 上要用 clear,跨平台写法:os.system('cls' if os.name == 'nt' else 'clear')。
    1
    2
    3
    import os

    print(os.system('cmd /c exit 3')) # 让 cmd 以退出码 3 结束

    输出:

    1
    3
  • name:操作系统名称

    • os.name:Windows 是 'nt',Linux / macOS 等类 Unix 是 'posix',在 JVM(Jython)上是 'java'。
    • 想再细分(区分 Linux 和 macOS)用 sys.platform:Windows 'win32'、Linux 'linux'、macOS 'darwin'。
    1
    2
    3
    4
    import os, sys

    # nt 代表 Windows;要细分平台看 sys.platform
    print(os.name, sys.platform)

    输出:

    1
    nt win32
  • environ:环境变量

    • os.environ 是个映射;Windows 上它大小写不敏感(env['path'] 能取到 PATH),POSIX 上区分大小写,别赌。
    • 取不存在的键:下标写法抛 KeyError,get 写法返回 None(os.getenv 和 os.environ.get 一回事)。探测变量一律走 get。
    1
    2
    3
    4
    5
    6
    import os

    env = os.environ
    print(env['path'] == env['PATH']) # Windows 上为 True
    print(env.get('HELLO'))
    print(env.get('HELLO', '给个默认值'))

    输出:

    1
    2
    3
    True
    None
    给个默认值
  • getcwd:当前工作目录

    1
    2
    3
    import os

    print(os.path.isabs(os.getcwd())) # 给的一定是绝对路径

    输出:

    1
    True

    相关的还有 os.chdir(路径) 切换工作目录、os.curdir('.')、os.pardir('..')。

  • mkdir 与 makedirs:建目录

    函数 中间目录 目录已存在 典型异常
    os.mkdir('a/b') 不创建,父目录不在就失败 报错 FileNotFoundError / FileExistsError
    os.makedirs('a/b') 一路补齐 报错,除非 exist_ok=True FileExistsError
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import os, tempfile

    os.chdir(tempfile.mkdtemp()) # 进临时目录,别脏了自己的盘

    os.mkdir('hello') # 单层没问题
    try:
    os.mkdir('deep/x') # 父目录 deep 不存在
    except FileNotFoundError:
    print('mkdir 只能建单层')
    os.makedirs('deep/x/y') # 同样的路径交给 makedirs
    print(os.path.isdir('deep/x/y'))
    os.makedirs('deep/x/y', exist_ok=True) # 重复创建不报错
    try:
    os.makedirs('deep/x/y') # 不给 exist_ok 还是报错
    except FileExistsError:
    print('makedirs 已存在会报 FileExistsError')

    输出:

    1
    2
    3
    mkdir 只能建单层
    True
    makedirs 已存在会报 FileExistsError
    • os.rmdir(目录):只能删空目录。非空抛 OSError(Windows 上 e.winerror 是 145「目录不是空的」),不存在抛 FileNotFoundError。
    • os.remove(文件) / os.unlink(文件):删文件,两个名字一回事(unlink 是 Unix 的习惯叫法)。传目录进去,Windows 抛 PermissionError,POSIX 抛 IsADirectoryError。
    • os.removedirs(路径):不是「递归删除目录」。它做的是「删掉叶子目录,然后一路往上删那些空了的上级目录」;往上删时遇到删不掉的(非空、没权限)就静默停下、不报错。只有叶子本身删不掉时才会抛 OSError。
    • 真要把非空目录整棵删掉,用 shutil.rmtree(路径)——标准库里的 rm -rf,别自己写递归。
    • Windows 上有个坑:shutil.rmtree 删不掉进程当前所在的目录(PermissionError,winerror 32),要先 os.chdir 跳出去再删。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    import os, tempfile

    os.chdir(tempfile.mkdtemp())

    os.makedirs('a/b/c')
    open('a/b/c/secret.txt', 'w').close()
    try:
    os.rmdir('a/b/c') # 里面还有文件
    except OSError as e:
    print('rmdir 非空目录:', type(e).__name__, e.winerror)
    os.remove('a/b/c/secret.txt') # 先清文件(os.unlink 效果一样)
    os.rmdir('a/b/c')
    print('清空后 rmdir 成功:', not os.path.exists('a/b/c'))

    os.makedirs('p/q/r') # p 里放个文件,让「往上删」的时候卡住
    open('p/top.txt', 'w').close()
    os.removedirs('p/q/r') # 不抛异常
    print('r 删了:', not os.path.exists('p/q/r'), 'q 删了:', not os.path.exists('p/q'), ' p 还在:', os.path.exists('p'))
    try:
    os.removedirs('p') # 这次卡在第一下:叶子 p 非空
    except OSError:
    print('removedirs 只有叶子删不掉时才抛 OSError')

    输出:

    1
    2
    3
    4
    rmdir 非空目录: OSError 145
    清空后 rmdir 成功: True
    r 删了: True q 删了: True p 还在: True
    removedirs 只有叶子删不掉时才抛 OSError
  • rename / renames / replace:改名与移动

    • os.rename(old, new):文件和目录都能改;改成不同目录的路径,效果就是「移动」。
    • 平台差异:目标已存在时,Windows 抛 FileExistsError,POSIX 直接覆盖。要跨平台地覆盖,用 os.replace(old, new)。
    • os.renames(old, new):比 rename 多做两件事——新路径的中间目录帮你建出来,旧路径留下的空上级目录顺手删掉。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    import os, tempfile

    os.chdir(tempfile.mkdtemp())
    open('old.txt', 'w').write('1')
    open('target.txt', 'w').write('2')

    os.rename('old.txt', 'renamed.txt')
    print('改名:', os.path.exists('renamed.txt'), '旧名字没了:', not os.path.exists('old.txt'))
    try:
    os.rename('renamed.txt', 'target.txt') # Windows 上目标已存在
    except FileExistsError:
    print('Windows 上 rename 不覆盖已存在的目标')
    os.replace('renamed.txt', 'target.txt') # 覆盖语义明确
    print('replace 之后:', os.path.exists('target.txt'), '源消失:', not os.path.exists('renamed.txt'))

    os.makedirs('rn/a/b')
    os.renames('rn/a/b', 'rn2/c/d') # 新的中间目录自动建
    print('renames:', os.path.isdir('rn2/c/d'), '旧的空上级 rn 被顺手删了:', not os.path.exists('rn'))

    输出:

    1
    2
    3
    4
    改名: True 旧名字没了: True
    Windows 上 rename 不覆盖已存在的目标
    replace 之后: True 源消失: True
    renames: True 旧的空上级 rn 被顺手删了: True
  • stat / listdir / scandir / walk:看信息

    • os.stat(路径) 返回 os.stat_result,常用字段:st_size(字节数)、st_mtime(修改时间戳,浮点数)、st_mode(类型与权限位)。
    • os.listdir(目录) 返回名字列表,不含路径——要判断或打开,必须先 os.path.join 拼回去。
    • os.scandir(目录) 返回 DirEntry 迭代器,entry.name / entry.is_file() / entry.is_dir() 都现成,比 listdir 之后再一个个 isdir 快(少一轮系统调用)。
    • os.walk(目录) 是生成器,每层产出 (当前目录, 子目录列表, 文件列表) 三元组,递归遍历目录树就靠它。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import os, tempfile

    os.chdir(tempfile.mkdtemp())
    os.makedirs('tree/sub')
    open('tree/x.py', 'w').write('12345')
    open('tree/y.py', 'w').close()

    # 取大小、时间戳等元信息
    st = os.stat('tree/x.py')
    print('st_size =', st.st_size, ' st_mtime 是浮点数:', isinstance(st.st_mtime, float))
    print('listdir 只给名字:', sorted(os.listdir('tree')))
    print('scandir:', sorted((e.name, e.is_file()) for e in os.scandir('tree')))
    # 每层给一个三元组,递归遍历靠它
    for dirpath, dirs, files in os.walk('tree'):
    print('walk:', os.path.relpath(dirpath, 'tree'), sorted(dirs), sorted(files))

    输出:

    1
    2
    3
    4
    5
    st_size = 5  st_mtime 是浮点数: True
    listdir 只给名字: ['sub', 'x.py', 'y.py']
    scandir: [('sub', False), ('x.py', True), ('y.py', True)]
    walk: . ['sub'] ['x.py', 'y.py']
    walk: sub [] []
  • 相对路径、绝对路径与分隔符

    • 相对路径:有参考目录的写法。. 表示当前目录(./xxx 里的 ./ 通常可省),.. 表示上一级。
    • 绝对路径:Windows 以盘符开头(C:\...)或以 \\ 开头(UNC 网络路径),类 Unix 以 / 开头。
    • 分隔符:类 Unix 只有 /;Windows 两种都认(/ 和 \)。代码里统一写 /,或者干脆全部用 os.path.join,别手拼反斜杠——路径以 \ 结尾时还会和字符串转义打架。
    • 相对路径的参考点是当前工作目录(os.getcwd()),不是脚本所在目录。脚本要读自己旁边的文件,用 os.path.dirname(os.path.abspath(__file__)) 拼。
  • 整体示例(目录管理)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    import os, shutil, sys, tempfile

    root = tempfile.mkdtemp(prefix='fn5_')
    os.chdir(root) # 之后的相对路径都落在临时目录里

    # 1) 建
    os.mkdir('hello')
    os.makedirs('a/b/c')
    print('建目录:', os.path.isdir('hello'), os.path.isdir('a/b/c'))
    # 2) 看
    open('a/b/c/secret.txt', 'w').write('12345')
    st = os.stat('a/b/c/secret.txt')
    print('stat:', st.st_size, isinstance(st.st_mtime, float))
    print('listdir:', os.listdir('a/b/c'), '拼回去才能判断:',
    os.path.isfile(os.path.join('a/b/c', 'secret.txt')))
    for dirpath, dirs, files in os.walk('a'):
    print('walk:', dirpath.replace(os.sep, '/'), dirs, files)
    # 3) 改
    os.replace('a/b/c/secret.txt', 'a/b/c/keep.txt')
    print('replace 改名:', os.path.isfile('a/b/c/keep.txt'))
    # 4) 删:先文件后目录,往上删空的交给 removedirs
    os.remove('a/b/c/keep.txt')
    os.rmdir('a/b/c')
    os.removedirs('a/b') # b 空了,连带 a 一起删
    print('删完:', not os.path.exists('a'), 'hello 还在:', os.path.isdir('hello'))
    # 5) 平台与环境
    print('os.name:', os.name, 'sys.platform:', sys.platform)
    print('PATH 取到没有:', bool(os.environ.get('PATH')), '不存在的键:', os.environ.get('HELLO'))
    print('system 返回退出码:', os.system('cmd /c exit 3') if os.name == 'nt' else os.system('exit 3'))
    # 6) 收尾:非空目录整棵删掉用 shutil.rmtree
    # Windows 上不能删「自己正站着的目录」(PermissionError, winerror 32),先跳出去
    os.chdir(tempfile.gettempdir())
    shutil.rmtree(root)
    print('清理:', not os.path.exists(root))

    实跑输出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    建目录: True True
    stat: 5 True
    listdir: ['secret.txt'] 拼回去才能判断: True
    walk: a ['b'] []
    walk: a/b ['c'] []
    walk: a/b/c [] ['secret.txt']
    replace 改名: True
    删完: True hello 还在: True
    os.name: nt sys.platform: win32
    PATH 取到没有: True 不存在的键: None
    system 返回退出码: 3
    清理: True

os.path:路径处理

这些函数大多只做字符串运算(只有 exists / isdir / isfile / getsize / realpath 会真的去问文件系统),所以传进去的路径存不存在它们都不报错。写法上 import os 之后用 os.path.xxx,或者 from os import path 之后用 path.xxx。

  • 常用函数实测

    在 Windows 上跑,注意分隔符会被换成本平台的 \:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    from os import path

    # Windows 上会拼出混合分隔符
    print(path.join('abc/def', '123.py'))
    # 可以多段一次拼
    print(path.join('a', 'b', 'c'))
    print(path.join('a', 'C:/x'), '(遇到绝对路径参数,前面的全丢)')
    # 切后缀,注意无后缀和隐藏文件
    print(path.splitext('789.py'), path.splitext('noext'), path.splitext('.hidden'))
    # 最后一个分隔符之前
    print(path.dirname('132/456/789.py'))
    # 之后;反斜杠也认
    print(path.basename('132/456/789.py'), path.basename('a\\b\\c.py'))
    # 一次拿到上面两个
    print(path.split('132/456/789.py'))
    # 就地化简 . 和 ..
    print(path.normpath('a/./b/../c'))
    # 拆出盘符
    print(path.splitdrive('C:/a/b'))
    # 判断是否绝对路径
    print(path.isabs('ld'), path.isabs('C:/a'))

    输出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    abc/def\123.py
    a\b\c
    C:/x (遇到绝对路径参数,前面的全丢)
    ('789', '.py') ('noext', '') ('.hidden', '')
    132/456
    789.py c.py
    ('132/456', '789.py')
    a\c
    ('C:', '/a/b')
    False True

    几个要点:

    • join 只负责「按本平台的规则拼」,输入里混着 / 它不帮你换,所以产物可能是 abc/def\123.py 这种混合分隔符——Windows 上照样能用,但别拿它去和字符串字面量比相等。
    • join 的参数里一旦出现绝对路径(C:/x 或 /y),前面全部作废。这是「以某个绝对路径为基准拼子路径」的常用写法,不是 bug。
    • splitext('.hidden') 认为它没有后缀(返回 ('.hidden', '')),开头的点不算后缀分隔点。
    • dirname / basename / split 就是「最后一个分隔符前 / 后」的关系,split 一次给你两个。
    • normpath 会把 .、.. 就地化简,并把分隔符统一成本平台的。
  • 判断类函数

    exists(存在吗,文件或目录都算)、isdir、isfile、getsize、abspath / realpath。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import os, tempfile
    from os import path

    os.chdir(tempfile.mkdtemp())
    os.makedirs('c')
    with open(path.join('c', '123.py'), 'w', encoding='utf-8') as f:
    f.write('12345')

    print(path.exists('c'), path.exists('ghost'))
    print(path.isdir('c'), path.isfile('c'), path.isfile(path.join('c', '123.py')))
    print(path.getsize(path.join('c', '123.py')))
    print(path.isabs(path.abspath('c')))
    print(path.realpath('c') == path.abspath('c')) # 没有软链接时两者相同

    输出:

    1
    2
    3
    4
    5
    True False
    True False True
    5
    True
    True

    判断一定要先把目录和文件名拼好。path.exists('123.py') 和 path.exists(path.join('c', '123.py')) 判的是两个完全不同的位置——练习题里那句「中间使用的所有文件目录的判断都需要进行拼接」说的就是这个。

  • 整体示例(路径处理)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    import os, shutil, tempfile
    from os import path

    os.chdir(tempfile.mkdtemp())
    os.makedirs('project/src/pkg')
    open('project/src/pkg/main.py', 'w').write('print(1)')
    open('project/readme', 'w').close() # 没有后缀的文件

    base = 'project'
    for name in os.listdir(base):
    full = path.join(base, name) # listdir 给的是名字,先拼
    print(name, '目录' if path.isdir(full) else '文件',
    '有后缀' if path.splitext(name)[1] else '无后缀')

    # 用 splitext 归类(目录整理练习的核心)
    for dirpath, dirs, files in os.walk('project'):
    for f in files:
    ext = path.splitext(f)[1].lstrip('.').upper() or 'OTHERS'
    print(path.relpath(path.join(dirpath, f), 'project'), '->', ext)

    # 路径信息一次拿全
    target = 'project/src/pkg/main.py'
    print('dirname:', path.dirname(target))
    print('basename:', path.basename(target))
    print('split:', path.split(target))
    print('abspath 比原路径长:', len(path.abspath(target)) > len(target))
    print('normpath:', path.normpath('project/./src/../src/pkg'))
    here = os.getcwd()
    os.chdir(tempfile.gettempdir()) # Windows 上不能删自己站着的目录
    shutil.rmtree(here)

    实跑输出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    readme 文件 无后缀
    src 目录 无后缀
    readme -> OTHERS
    src\pkg\main.py -> PY
    dirname: project/src/pkg
    basename: main.py
    split: ('project/src/pkg', 'main.py')
    abspath 比原路径长: True
    normpath: project\src\pkg

文件管理

  • 打开文件:open(文件路径, 打开方式, encoding=...)

    • 参数一:文件路径(相对、绝对都行)。
    • 参数二:打开方式(模式),见下表。
    • 参数三:encoding。原笔记写「通常可以不指定,系统会自动识别处理」,这句是错的:不指定时 Python 用的是系统区域编码(本机 locale.getpreferredencoding(False) 返回 cp936,就是 GBK),它不会去「识别」文件本身是什么编码。后果是 Windows 上写的文件,拿到 Linux/macOS 上读就乱码甚至直接抛异常。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    import os, tempfile

    os.chdir(tempfile.mkdtemp())
    with open('gbk.txt', 'w') as f: # 故意不写 encoding
    f.write('中文')
    print(open('gbk.txt', 'rb').read()) # 落盘其实是 GBK 字节
    try:
    open('gbk.txt', encoding='utf-8').read()
    except UnicodeDecodeError:
    print('按 utf-8 读它会直接报错')
    with open('u8.txt', 'w', encoding='utf-8') as f: # 显式指定才是稳的
    f.write('中文')
    print(open('u8.txt', encoding='utf-8').read())

    输出:

    1
    2
    3
    b'\xd6\xd0\xce\xc4'
    按 utf-8 读它会直接报错
    中文

    结论:文本文件永远显式写 encoding='utf-8'。

  • 七种模式

    模式 读 写 文件不存在 文件已存在 初始指针
    r(默认) 是 否 FileNotFoundError 保留内容 0
    w 否 是 新建 清空内容 0
    a 否 是 新建 保留;写入总在末尾 0(写之前会跳到末尾)
    r+ 是 是 FileNotFoundError 保留内容 0
    w+ 是 是 新建 清空内容 0
    a+ 是 是 新建 保留;写入总在末尾 末尾
    x 否 是 新建 FileExistsError 0

    模式后加 b(rb / wb / ab / rb+ / wb+ / ab+ / xb)就是二进制:读写的是 bytes;不加 b 的都是 str。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import os, tempfile

    os.chdir(tempfile.mkdtemp())
    # 每个模式配一个自己的文件名,不然前面的 w 已经把文件建出来了,x 会被抢跑
    for mode in ('r', 'r+', 'w', 'w+', 'a', 'a+', 'x'):
    try:
    open(f'p_{mode}.txt', mode).close()
    print(f'{mode:3} 打开成功')
    except OSError as e:
    print(f'{mode:3} {type(e).__name__}')
    try:
    open('p_x.txt', 'x') # 这个已经存在了
    except OSError as e:
    print('x 遇到已存在的文件:', type(e).__name__)

    输出:

    1
    2
    3
    4
    5
    6
    7
    8
    r   FileNotFoundError
    r+ FileNotFoundError
    w 打开成功
    w+ 打开成功
    a 打开成功
    a+ 打开成功
    x 打开成功
    x 遇到已存在的文件: FileExistsError

    读、写两种模式对「文件不存在」的态度一目了然:要读就必须已存在,要写就帮你建。x 的意义是「我只新建,已存在就别动它」,常用来防覆盖。

  • 为什么 w+ / a+ 直接读是空

    • w+:一打开就把文件清空,所以读到空——哪怕你一个字节都没写。
    • a+:内容还在,但指针默认在末尾,而读是从指针位置往后读,所以读出空;要先 seek(0)。
    • a / a+ 的写操作永远落在末尾,中途 seek(0) 也改变不了这件事(想原位覆盖得用 r+ / w+)。
    • 空文件的话,所有模式打开后指针都是 0。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import os, tempfile

    os.chdir(tempfile.mkdtemp())
    with open('ap.txt', 'w') as f:
    f.write('hello')
    # 可读写,但指针一开始在末尾
    with open('ap.txt', 'a+') as f:
    print('a+ 初始指针:', f.tell(), '直接 read:', repr(f.read()))
    f.seek(0)
    print('seek(0) 之后 read:', repr(f.read()))
    # 一打开就清空
    with open('ap.txt', 'w+') as f:
    print('w+ 一打开就清空:', repr(f.read()))
    with open('ap.txt', 'w') as f:
    f.write('hello')
    # 只写;写永远落在末尾
    with open('ap.txt', 'a') as f:
    f.seek(0)
    f.write('X')
    print('a 模式 seek 无效,还是追加:', repr(open('ap.txt').read()))

    输出:

    1
    2
    3
    4
    a+ 初始指针: 5 直接 read: ''
    seek(0) 之后 read: 'hello'
    w+ 一打开就清空: ''
    a 模式 seek 无效,还是追加: 'helloX'
  • 文件指针:tell 与 seek

    • fp.tell():当前指针位置。
    • fp.seek(偏移, 参考位置):参考位置 0 从开头算(默认)、1 从当前算、2 从末尾算,对应常量 io.SEEK_SET / io.SEEK_CUR / io.SEEK_END。
    • 文本模式的限制(原笔记只说了「相对当前不行」,其实相对末尾也不行):seek(1, 1)、seek(-5, 2) 都会抛 UnsupportedOperation。文本模式只认 seek(0)、seek(0, 2),以及「之前 tell() 拿回来的那个值」。二进制模式三种参考位置随便用。
    • tell() 在文本模式下是不透明值:它不等于字符数,只能原样拿回去喂给 seek,别拿它做算术。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    import os, tempfile

    os.chdir(tempfile.mkdtemp())
    with open('t.txt', 'w', encoding='utf-8') as f:
    f.write('hello world')
    with open('t.txt', encoding='utf-8') as f:
    # 参考位置 2 = 从末尾算
    f.seek(0, 2)
    print('seek(0,2) 到末尾:', f.tell(), repr(f.read()))
    # 参考位置 0 = 从开头算
    f.seek(6, 0)
    print('seek(6,0):', repr(f.read()))
    # 文本模式这两种参考位置都不让用
    for whence in (1, 2):
    try:
    f.seek(1, whence)
    except OSError:
    print(f'文本模式 seek(1,{whence}) 报错: UnsupportedOperation')
    # 换成二进制,限制就没了
    with open('t.txt', 'rb') as f:
    f.seek(1, 1)
    print('二进制 seek(1,1):', f.tell())
    # 从末尾倒着数
    f.seek(-5, 2)
    print('二进制 seek(-5,2):', f.read())

    输出:

    1
    2
    3
    4
    5
    6
    seek(0,2) 到末尾: 11 ''
    seek(6,0): 'world'
    文本模式 seek(1,1) 报错: UnsupportedOperation
    文本模式 seek(1,2) 报错: UnsupportedOperation
    二进制 seek(1,1): 1
    二进制 seek(-5,2): b'world'

    中文文件最能看出 tell() 不可解释:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import os, tempfile

    os.chdir(tempfile.mkdtemp())
    with open('zh.txt', 'w', encoding='utf-8') as f:
    f.write('中文字\n')
    f = open('zh.txt', encoding='utf-8')
    # 读一个字符(UTF-8 下是 3 字节)
    f.read(1)
    # tell 给的是不透明凭据,不是字符数
    print('读 1 个字符后 tell():', f.tell())
    print('拿这个值 seek 回去:', repr(f.read()))
    f.close()

    输出:

    1
    2
    读 1 个字符后 tell(): 3
    拿这个值 seek 回去: '文字\n'

    一个汉字在 UTF-8 里占 3 个字节,所以 tell() 给的是 3——它是字节层面的凭据,不是「读了 3 个字符」。

  • read(n):文本模式数「字符」,二进制模式数「字节」

    同一句 read(2),两种模式给的不是一回事:文本模式已经按 encoding 解码完了,给你 2 个字符;二进制模式不解码,给你 2 个字节。而一个汉字在 UTF-8 里要占 3 个字节,2 个字节只是它的三分之二,是一段残缺的字节序列——拿去 decode('utf-8') 会直接报错。

    写代码时这条的用处是:「二进制分块读 + 每块立刻解码」的写法,只要块大小不是字符字节数的整数倍,就会在半个汉字处切断。要么攒够了整块再解,要么用增量解码器把断点续上。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    import codecs, os, tempfile

    os.chdir(tempfile.mkdtemp())
    with open('zh.txt', 'w', encoding='utf-8', newline='') as f: # newline='' 不换行符
    f.write('中文字')

    print('文本模式 read(2):', repr(open('zh.txt', encoding='utf-8').read(2)))
    part = open('zh.txt', 'rb').read(2)
    print('二进制模式 read(2):', part, '—— 一个汉字要 3 字节,这里只有 2 字节')
    try:
    part.decode('utf-8')
    except UnicodeDecodeError as e:
    print('拿它解码:', type(e).__name__, e)
    print('二进制 read(3) 才凑满一个汉字:', open('zh.txt', 'rb').read(3).decode('utf-8'))

    dec = codecs.getincrementaldecoder('utf-8')()
    out = []
    with open('zh.txt', 'rb') as f:
    while chunk := f.read(2): # 每次只读 2 字节,一定会把汉字劈断
    out.append(dec.decode(chunk)) # 增量解码器会把断点续上
    print('增量解码器读全文:', repr(''.join(out)))

    输出:

    1
    2
    3
    4
    5
    文本模式 read(2): '中文'
    二进制模式 read(2): b'\xe4\xb8' —— 一个汉字要 3 字节,这里只有 2 字节
    拿它解码: UnicodeDecodeError 'utf-8' codec can't decode bytes in position 0-1: unexpected end of data
    二进制 read(3) 才凑满一个汉字: 中
    增量解码器读全文: '中文字'

    顺带一提:拷贝文件那种「二进制读一块、写一块」不受影响,因为压根不解码,字节原样搬运——所以分块拷贝用二进制模式,见下面整体示例的第 6 步。

  • 编码方式

    • ASCII:7 位,128 个字符,只有英文字母、数字和常用符号。
    • ANSI:Windows 语境下对「本地编码」的统称,不是「扩展 ASCII」:中文 Windows 的 ANSI 是 GBK 系(代码页 936),西欧是 Windows-1252,各说各话。
    • gb2312 / gbk / gb18030:中国的汉字编码标准,收录范围逐级扩大(GBK 向下兼容 GB2312)。中文 Windows 的系统编码就属于这一族。
    • Unicode:给每个字符编一个号(码点)的字符集标准,本身不规定怎么存成字节。
    • UTF-8:Unicode 的一种存储实现,变长 1~4 字节——英文 1 字节、中文通常 3 字节,既省空间又兼容 ASCII。另有 UTF-16(Windows 内部用)、UTF-32。
    • utf-8-sig:写文件时在开头带 3 字节 BOM(\xef\xbb\xbf),给只认「带 BOM 的 UTF-8」的老程序(比如老版 Excel)用;用普通 utf-8 读带 BOM 的文件,会把 BOM 读成一个 \ufeff 字符。
    1
    2
    3
    4
    5
    6
    print(b'hello'.decode('utf-8'))  # 解码 bytes -> str
    print('hello'.encode('utf-8')) # 编码 str -> bytes
    print('中文'.encode('utf-8'), len('中文'.encode('utf-8')))
    print('中文'.encode('gbk'), len('中文'.encode('gbk')))
    print('中'.encode('utf-8-sig'))
    print(hex(ord('中')))

    输出:

    1
    2
    3
    4
    5
    6
    hello
    b'hello'
    b'\xe4\xb8\xad\xe6\x96\x87' 6
    b'\xd6\xd0\xce\xc4' 4
    b'\xef\xbb\xbf\xe4\xb8\xad'
    0x4e2d
  • 读写接口

    方法 作用 实测备注
    read(n) 读 n 个单位 文本模式是 n 个字符,二进制是 n 个字节;不传就一次读完
    readline() 读一行 返回值包含行尾的 \n
    readlines() 读所有行 返回列表,每项含 \n;大文件别用,会一次进内存
    write(s) 写 不自动加换行,\n 自己写;返回写入的长度
    writelines(seq) 写一串 同样不补换行,元素得自己带
    readable() / writable() 能否读 / 能否写 r 是 True/False,w、a 是 False/True,r+ 两个都 True
    flush() 把缓冲区落盘 with 结束或 close() 时也会做
    close() 关闭 关闭后再读写抛 ValueError: I/O operation on closed file

    for line in fp 逐行遍历文件对象,结果和 readlines() 一致(也含 \n),但它一次只拿一行,读大文件不爆内存,优先用这种。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    import os, tempfile

    os.chdir(tempfile.mkdtemp())
    with open('lines.txt', 'w', encoding='utf-8') as f:
    # writelines 不补换行,元素自己带
    f.writelines(['hello\n', 'world\n', 'really\n'])
    f = open('lines.txt', encoding='utf-8')
    # 文件对象的 repr:模式和编码一目了然
    print(f)
    print('readable / writable:', f.readable(), f.writable())
    # 读完指针就越过这一行了
    print('readline:', repr(f.readline()))
    # 所以上一行已经不在结果里
    print('readlines 剩下:', f.readlines())
    f.close()
    with open('lines.txt', encoding='utf-8') as f:
    # 遍历文件对象等价于 readlines,但一次只拿一行
    print('for 逐行:', [x.rstrip() for x in f])
    print('close 之后:', f.closed)

    输出:

    1
    2
    3
    4
    5
    6
    <_io.TextIOWrapper name='lines.txt' mode='r' encoding='utf-8'>
    readable / writable: True False
    readline: 'hello\n'
    readlines 剩下: ['world\n', 'really\n']
    for 逐行: ['hello', 'world', 'really']
    close 之后: True

    readline() 之后指针已经越过第一行,所以紧接着的 readlines() 只给剩下两行——这就是「读之前一定要确认指针位置」的意思。

  • with 语句

    • 场景:文件操作中间无论读写、无论有没有异常,最后都必须关闭。
    • with:语句块结束时(包括抛异常、return 提前离开)保证调用 close(),不用自己操心。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    import os, tempfile

    os.chdir(tempfile.mkdtemp())
    open('test.txt', 'w').write('hello world')

    # 手工版:中间一旦抛异常,close() 就跑不到
    fp = open('test.txt', 'rb')
    print(fp.read(5))
    fp.close()

    # with 版
    with open('test.txt', 'rb') as fp:
    print(fp.read(5))
    try:
    with open('test.txt', 'rb') as fp:
    raise RuntimeError('boom')
    except RuntimeError:
    print('块里抛异常也照样关闭:', fp.closed)

    输出:

    1
    2
    3
    b'hello'
    b'hello'
    块里抛异常也照样关闭: True

    with 不限于文件,任何实现了上下文协议(__enter__ / __exit__)的对象都能用;多个资源可以写在一行:with open('a') as fa, open('b') as fb: ...。

  • 文件删除

    1
    2
    3
    4
    5
    6
    import os, tempfile

    os.chdir(tempfile.mkdtemp())
    open('02-test.py', 'w').close()
    os.remove('02-test.py') # os.unlink 效果一样
    print('删掉了:', not os.path.exists('02-test.py'))

    输出:

    1
    删掉了: True

    目录用 os.rmdir(只能空目录)或 shutil.rmtree(整棵树)。

  • 整体示例(文件读写全流程)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    import os, shutil, tempfile

    os.chdir(tempfile.mkdtemp())

    # 1) 写:显式指定编码;write 不补换行要自己加
    with open('note.txt', 'w', encoding='utf-8') as f:
    f.write('第一行\n')
    f.writelines(['第二行\n', '第三行\n'])

    # 2) 读法一:逐行遍历(大文件友好)
    with open('note.txt', encoding='utf-8') as f:
    for line in f:
    print('行:', line.rstrip())

    # 3) 读法二:read(n) 在文本模式按字符算
    with open('note.txt', encoding='utf-8') as f:
    print('前 3 个字符:', repr(f.read(3)))
    print('指针位置:', f.tell())

    # 4) 指针:seek 三种参考位置,文本模式只认 0、2 和 tell 回来的值
    with open('note.txt', encoding='utf-8') as f:
    # 参考位置 2 = 从末尾算
    f.seek(0, 2)
    print('到末尾 tell:', f.tell(), '读:', repr(f.read()))
    f.seek(0)
    print('回到开头读一行:', repr(f.readline()))

    # 5) a+ 的坑:指针在末尾,读之前先 seek(0);写永远落在末尾
    with open('note.txt', 'a+', encoding='utf-8') as f:
    print('a+ 直接读:', repr(f.read()))
    f.seek(0)
    print('seek(0) 后读第一行:', repr(f.readline()))
    f.write('追加的一行\n')
    print('文件现在:', repr(open('note.txt', encoding='utf-8').read()))

    # 6) 拷贝(超大文件分块读写)+ 校验
    CHUNK = 4
    with open('note.txt', 'rb') as src, open('copy.txt', 'wb') as dst:
    while True:
    block = src.read(CHUNK)
    if not block:
    break
    dst.write(block)
    print('拷贝后大小一致:', os.path.getsize('note.txt') == os.path.getsize('copy.txt'))

    # 7) 收尾
    print('目录内容:', sorted(os.listdir('.')))
    here = os.getcwd()
    os.chdir(tempfile.gettempdir()) # Windows 上不能删自己站着的目录
    shutil.rmtree(here)

    实跑输出:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    行: 第一行
    行: 第二行
    行: 第三行
    前 3 个字符: '第一行'
    指针位置: 9
    到末尾 tell: 33 读: ''
    回到开头读一行: '第一行\n'
    a+ 直接读: ''
    seek(0) 后读第一行: '第一行\n'
    文件现在: '第一行\n第二行\n第三行\n追加的一行\n'
    拷贝后大小一致: True
    目录内容: ['copy.txt', 'note.txt']

总练习

  1. 实现一个拷贝文件的功能。提醒:要考虑超大文件,一次读 1024 字节,循环读、循环写(上面整体示例第 6 步就是这个思路,把 CHUNK 换成 1024 即可)。
  2. 递归删除一个文件夹(自己用 os.listdir + os.remove / os.rmdir 实现一遍,再和 shutil.rmtree 对比)。
  3. 统计一个文件夹的大小(os.walk + os.path.getsize,注意 getsize 要拼完整路径)。
  4. 拷贝一个文件夹(先 os.makedirs 建出目录树,再逐个拷贝文件)。
  5. 移动一个文件夹(同盘用 os.rename 最快;跨盘会抛 OSError,这时得「拷完再删」)。
  6. 目录整理:
    • 一个目录中有各种文件,也有文件夹
    • 将所有的文件夹统一放到 DIR 目录下
    • 将没有后缀的文件统一放到 others 目录下
    • 将有后缀的文件放到后缀名大写的文件夹下
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      # 处理前
      test/
      dir1/
      dir2/
      1.py
      2.py
      3.txt
      4.pdf
      123
      456

      # 处理后
      test/
      DIR/
      dir1/
      dir2/
      PY/
      1.py
      2.py
      TXT/
      3.txt
      PDF/
      4.pdf
      OTHERS/
      123
      456

      友情提醒:中间用到的每一个「存在吗 / 是目录吗 / 多大」判断,都要先把目录和文件名 os.path.join 拼好;直接拿裸名字判断,判的是当前工作目录下的同名对象。

参考

本文结束 感谢您的阅读
正在加载今日诗词....