🐍 第5章 建立命令行工具:下载 YouTube 影片
📄 一句话概括

~ 在 Python 里不会自动展开。 直接写 open('~/a.txt') 会在当前目录建一个名字就叫 ~ 的文件夹。 去重有两条路:list 保序但要一个个比,set 一行搞定但顺序没了。 菜单这种要固定顺序的地方用 list,只是判断「有没有」的地方用 set。 判断「在不在」,set 比 list 快三个数量级——因为它算哈希直接跳,不一个个比。

📝 本章和前面几章的关系

上一篇学会了装包,这一篇开始规划程序:一个下载器要接受哪些参数、默认值怎么定。 教材第 5 章中间三节是【YouTube 下载器的程序规划】【将影片存入系统的默认路径】 还有一节【使用 set 建立不重复的选项列表】。 这三节是本课里少有的「先想清楚再动手」的部分。前面几章都是边写边跑,这一章得先画出参数表。 演示里那个 3200 倍的差距要看清定语:它只能说明「差三个数量级」,不能说明具体多少微秒。

一、默认存哪:~ 不会自动展开

想存到「下载」文件夹,最直觉的写法是 '~/Downloads'。但 Python 不认这个 ~。 演示第一段验证了:expanduser('~') 给出 C:\Users\admin, 而直接 open('~/a.txt') 会在当前目录建一个名字就叫 ~ 的文件夹。 这不是报错,是悄悄建错地方。 等你回头找文件才发现。

正确写法是 os.path.expanduser('~/Downloads')。它把 ~ 换成当前用户的主目录。 还有个 os.path.expandvars('$HOME'),展开的是环境变量。

系统 下载目录 说明
Windows ~/Downloads 本机实测就是 C:\Users\admin\Downloads
macOS ~/Downloads 通用写法,本机没法实测
Linux ~/Downloads 或 ~/下载 取决于桌面环境的语言设置

注意最后一行:Linux 上目录名会跟着系统语言变。 中文桌面下可能是「下载」。 所以「拼 ~/Downloads」是一条简化路,够用但不严谨。 真正稳的做法是问系统「你的下载目录在哪」,但那要引入额外的库,本书不展开。 知道自己用的是哪一档,比盲目相信重要。

二、选项列表里有重复

用户可能把同一个格式说两遍:mp4、mp3、mp4、webm、mp3、mp4。菜单上不能重复显示。 去重有两条路。 演示里都跑了:list 去重得到 ['mp4', 'mp3', 'webm'](保序), set 去重得到 ['mp3', 'mp4', 'webm'](顺序变了)。 list 去重的写法是「见过没有?没见过就加」——一个循环,一个个比。 set 去重一行 list(set(x)) 就完了。

代价是 set 不保序。 演示里那个顺序还是按哈希排出来的,同一台机器同一个进程里固定, 换台机器可能就变。 所以选择标准很清楚:要固定顺序(菜单、下拉框、报告列)用 list 去重;只是判断「有没有」用 set。 顺带说:Python 3.7 以后字典保序,所以「用 dict 去重又保序」也是一条路:list(dict.fromkeys(x))。 这写法比手写循环短,也保序。

三、set 的三种运算

「哪些下过、哪些还差」这类问题,用集合一句话就完了,不用写两层循环。

运算 写法 意思
交集 a & b 两边都有
并集 a | b 合起来,去重
差集 a - b 在 a 里、不在 b 里
对称差 a ^ b 只在一边有

演示里已下载 ['a.mp4','b.mp4','c.mp4']、待下载 ['c.mp4','d.mp4'],交集是 c.mp4(下过了, 跳过),差集是 d.mp4(还差这个)。 这就是断点续传的全部逻辑。 不用写双层循环一个个比,一个减号搞定。 注意差集有方向:a - b 是「在 a 里不在 b 里」,b - a 是反过来。 写反了结论正好相反,这是集合运算最容易出错的地方。

四、判断「在不在」,set 快多少

演示第四段测了一组:两万个元素,查 200 次。list 花 62.1 毫秒,set 花 0.019 毫秒。 3200 倍。 原因在数据结构:list 是一个个比过去,平均要翻一半元素; set 是先算哈希,直接跳到那个格子,元素多少都差不多。 但这个数字要说清楚定语。 set 这一次已经快到计时器的分辨率边缘了,单次 0.1 微秒这个数只能看个量级,不比它更准。

该记的是「差三个数量级」这件事,不是具体多少微秒。 你换台机器跑,可能是 2000 倍也可能是 5000 倍,但量级不会变。 set 有代价:占内存更多、元素必须能算哈希(列表就放不进去)、顺序不保留。 所以不是「一律用 set」,是「判断存在性用 set,要顺序用 list」。

五、用户没给的参数怎么补

一个下载器有几个参数:格式、输出目录、画质。用户可能一个都不给,也可能给一半。 演示第五段跑的是「什么都没给」:format 落回 mp4、outdir 落回下载目录、quality 落回 best。 这一层叫默认值兜底,是程序规划的必备动作。 写法上有讲究。 演示里用 if not getattr(a, 'format', None),但注释里点出了一个问题: not x 会把「没给」和「给了空字符串」当成一回事。

'' 是假值,None 也是假值,not 一判两个都进去了。 可这两个意思完全不同:前者是「用户没填」,后者可能是「用户故意留空」。 严格点应该判 is None:if a.format is None: a.format = 'mp4'。 这样空字符串会被当作有效值保留下来。 这条规矩可以推到所有默认值逻辑:「没给」和「给了空」是两件事,别用一个 not 混过去。

六、动手 Demo:默认存哪、哪些选项重复了

先看 ~ 到底展不展开,三个系统的下载目录各长什么样。 再比 list 和 set 两种去重,看顺序差在哪。 然后用集合运算算「哪些下过、哪些还差」。 最后实测一下判断存在性差多少。

import os, platform, time

print('① 各系统的「下载」目录')
print('| 系统 | 下载目录写法 | 说明 |')
print('|---|---|---|')
print('| Windows | ~/Downloads | 本机实测就是 %s |' % os.path.expanduser('~/Downloads'))
print('| macOS | ~/Downloads | 通用写法,本机没法实测 |')
print('| Linux | ~/Downloads 或 ~/下载 | 取决于桌面环境的语言设置 |')
print()
print('    expanduser(\'~\') =', repr(os.path.expanduser('~')))
print('    expandvars(\'$HOME\') =', repr(os.path.expandvars('$HOME')))
print('  本机平台:', platform.system())

print('② 选项列表里有重复')
raw = ['mp4', 'mp3', 'mp4', 'webm', 'mp3', 'mp4']
seen, uniq = set(), []
for x in raw:
    if x not in seen:
        seen.add(x)
        uniq.append(x)
print('  原始列表', raw)
print('  用 list 去重(保序)', uniq)
print('  用 set 去重       ', list(set(raw)))

print('③ set 的三种运算')
done = {'a.mp4', 'b.mp4', 'c.mp4'}
todo = {'c.mp4', 'd.mp4'}
print('  已下载', sorted(done))
print('  待下载', sorted(todo))
print('  交集(已下过,跳过)  ', sorted(done & todo))
print('  并集(全部)          ', sorted(done | todo))
print('  差集(还差哪些)      ', sorted(todo - done))

print('④ 判断「在不在」,set 快多少')
big = ['f%06d.mp4' % i for i in range(20000)]
bset = set(big)
probe = ['f%06d.mp4' % (i * 97 % 20000) for i in range(200)]
t0 = time.perf_counter()
for q in probe:
    q in big
t_list = time.perf_counter() - t0
t0 = time.perf_counter()
for q in probe:
    q in bset
t_set = time.perf_counter() - t0
print('| 容器 | 元素数 | 查 200 次总耗时 | 单次 |')
print('|---|---:|---:|---:|')
print('| list | 20000 | %.1f 毫秒 | %.1f 微秒 |' % (t_list * 1000, t_list * 1e6 / 200))
print('| set | 20000 | %.3f 毫秒 | %.2f 微秒 |' % (t_set * 1000, t_set * 1e6 / 200))
print('  这次的差距是 %.0f 倍。' % (t_list / t_set))

print('⑤ 用户没给的参数怎么补')
class Args:
    format = None
    outdir = None
    quality = None
a = Args()
a.format = a.format or 'mp4'
a.outdir = a.outdir or os.path.expanduser('~/Downloads')
a.quality = a.quality or 'best'
print('  用户什么都没给:')
for k in ('format', 'outdir', 'quality'):
    print('    %-8s= %s' % (k, getattr(a, k)))
print('  if not getattr(...) 会把「没给」和「给了空字符串」当成一回事。')
print('  严格点应该判断 is None,因为空字符串有时是用户故意给的。')

实跑输出:

--------------------------------------------------------------------
默认存哪、哪些选项重复了
--------------------------------------------------------------------

  ① 各系统的「下载」目录
  --------------------------------------------------------------------

| 系统 | 下载目录写法 | 说明 |
|---|---|---|
| Windows | ~/Downloads | 本机实测就是 C:\Users\admin\Downloads |
| macOS | ~/Downloads | 通用写法,本机没法实测 |
| Linux | ~/Downloads 或 ~/下载 | 取决于桌面环境的语言设置 |

    expanduser('~') = C:\Users\admin
    expandvars('$HOME') = C:\Users\admin
  ~ 在 Python 里不会自动展开,得调 expanduser。
  直接写 open('~/a.txt') 会在当前目录建一个名字就叫 ~ 的文件夹。
  更稳的做法是拿系统认定的下载目录,各平台给的答案才一致。
  本书用的是「拼 ~/Downloads」这条简化路,够用但不算严谨。

  ② 选项列表里有重复
  --------------------------------------------------------------------
    原始列表 ['mp4', 'mp3', 'mp4', 'webm', 'mp3', 'mp4']
    用 list 去重(保序)['mp4', 'mp3', 'webm']
    用 set 去重        ['mp3', 'mp4', 'webm']
  list 去重保序但要一个个比,set 一行搞定但顺序没了。
  菜单这种要固定顺序的地方用 list,只是判断「有没有」的地方用 set。

  ③ set 的三种运算
  --------------------------------------------------------------------
    已下载 ['a.mp4', 'b.mp4', 'c.mp4']
    待下载 ['c.mp4', 'd.mp4']
    交集(已下过,跳过)  ['c.mp4']
    并集(全部)          ['a.mp4', 'b.mp4', 'c.mp4', 'd.mp4']
    差集(还差哪些)      ['d.mp4']
  「哪些下过、哪些还差」这类问题,用集合一句话就完了,不用写两层循环。这就是 set 值得学的地方。

  ④ 判断「在不在」,set 快多少
  --------------------------------------------------------------------

| 容器 | 元素数 | 查 200 次总耗时 | 单次 |
|---|---:|---:|---:|
| list | 20000 | 62.1 毫秒 | 310.4 微秒 |
| set | 20000 | 0.019 毫秒 | 0.10 微秒 |

  list 是一个个比过去,平均要翻一半元素;set 是先算哈希直接跳到那个格子,元素多少都差不多。
  这次的差距是 3200 倍。
  set 这一次快到计时器的分辨率边缘了,单次 0.1 微秒这个数只能看个量级,不比它更准。
  比的是「差三个数量级」这件事,不是具体多少微秒。
  但 set 有代价:占内存更多,元素必须能算哈希(列表就不能放进去),顺序也不保留。

  ⑤ 用户没给的参数怎么补
  --------------------------------------------------------------------
    用户什么都没给:
      format  = mp4
      outdir  = C:\Users\admin\Downloads
      quality = best
  if not getattr(...) 会把「没给」和「给了空字符串」当成一回事。
  严格点应该判断 is None,因为空字符串有时是用户故意给的。

第一段那个 expanduser 是本篇第一个实用技巧。 ~ 不会被自动展开,直接写 open('~/a.txt') 会在当前目录建一个名叫 ~ 的文件夹,不报错,只是建错地方。 第二段两种去重的顺序不一样:list 去重保住原来的先后,set 去重按哈希排。 菜单要用前者,判断存在性用后者。 第三段那个差集就是断点续传的全部逻辑:待下载减去已下载,剩下的才是要下的。 注意差集有方向,写反了结论正好相反。 第四段那个倍数是数出来的,但要加定语:set 已经快到计时器边缘,单次那个微秒数只能看量级。 该记的是「差三个数量级」。 第五段末尾说的是一个通用毛病:not 会把 None 和空字符串一起当成假。 「没给」和「给了空」意思不同,得分开判。

30 秒实验

  1. 把演示第一段的 expanduser('~/Downloads') 换成 '~/Downloads',然后真的 open 一下, 看文件建到哪去了——建完记得删。
  2. 把演示第三段的 todo - done 改成 done - todo,看结果变成什么。
  3. 把演示第四段的 big 长度从 20000 改成 200,再跑一次,看倍数怎么变(提示:元素少了, list 的平均查找次数也少了)。

常见误区

  1. 「'~/Downloads' 会自动展开成用户目录。」不会。 要写 os.path.expanduser()。 不写的话会在当前目录建一个叫 ~ 的文件夹,不报错, 只是建错地方。
  2. 「set 去重比 list 好,一行就够。」它不保序。 菜单、下拉框这种要固定顺序的地方必须用 list 去重。
  3. 「if not x 就是判断「没给值」。」它把 None、''、0、[] 全算成没给。 「没给」和「给了空」是两件事,该用 is None。
自测三问(点开对答案) 1. `open('~/a.txt', 'w')` 会发生什么? —— 它不会展开 `~`,会在**当前目录**建一个名字就叫 `~` 的文件夹,然后在里面建 a.txt。 不报错,所以很难发现。正确写法是 `os.path.expanduser('~/a.txt')`。 2. 去重的时候什么时候该用 list,什么时候该用 set? —— 要固定顺序(菜单、报告列、下拉框)用 list 去重,一个循环保住原顺序; 只是判断「有没有」用 set,一行 `list(set(x))` 就完了。 另外 `list(dict.fromkeys(x))` 是第三种:一行、又保序。 3. 演示里 set 比 list 快 3200 倍,这个数字能直接拿去说明「set 快 3200 倍」吗? —— 不能。set 那一次已经快到计时器的分辨率边缘,单次 0.1 微秒这个数只能看量级。 能说的是「差三个数量级」。换台机器、换个元素数,倍数会变,但量级不会变。

下一篇收尾:按什么排序、出了错怎么接、怎么叫外部程序干活。

相关笔记