Python的字符串判断把我坑惨了,原来startswith和endswith的区别这么大
小刘那天写了一个静态文件服务器的路由逻辑,需要根据请求路径判断该交给谁处理。规则很简单:路径以 `/api/` 开头的,交给 API 处理器;以 `.json` 结尾的,返回 JSON 内容;其他的当静态文件处理。 他写了一段代码: ``` def route(path): if path.endswith("/api/"): return handle_api(path) elif path.startswith(".json"): return serve_json(path) else: return serve_static(path) ``` 跑起来之后,所有请求都走到了 `serve_static`,API 和 JSON 服务完全失效。他盯着代码看了半天,嘴里念叨:“`/api/users` 明明是以 `/api/` 开头的啊,为什么 `endswith` 返回 False?” 旁边的同事探过头来,看了一眼说:“你把 `startswith` 和 `endswith` 用反了。`/api/` 是前缀,应该用 `startswith`;`.json` 是后缀,应该用 `endswith`。” 小刘愣了一下,然后拍了下脑门:“我把这俩搞混了。” 这个错误听起来很蠢,但真到写代码的时候,很多人会因为赶时间、变量名相似、或者纯粹的手滑,把这两个方法用反。更麻烦的是,用反了通常不会报错,程序会静默地走错分支,直到你发现某个功能完全没生效。 ## **先看一个最直观的对比** ``` s = "hello_world.py" print(s.startswith("hello")) # True print(s.endswith("hello")) # False print(s.startswith(".py")) # False print(s.endswith(".py")) # True ``` `startswith` 检查字符串是否以某个子串**开头**。`endswith` 检查字符串是否以某个子串**结尾**。一个看头,一个看尾,位置完全相反。 用一个生活场景来理解。你有一排人站在操场上,从左到右编号。 `startswith` 是问:“队伍最左边的人是不是叫张三?” `endswith` 是问:“队伍最右边的人是不是叫李四?” 一个问题看队头,一个问题看队尾。问反了,答案自然就错了。 ## **startswith:检查开头** `startswith` 的签名是 `str.startswith(prefix[, start[, end]])`。它检查字符串在指定范围内是否以 `prefix` 开头。 ``` path = "/api/users" print(path.startswith("/api/")) # True print(path.startswith("/api/user")) # True print(path.startswith("/admin")) # False print(path.startswith("api")) # False,因为开头是 "/",不是 "a" ``` 注意最后一个例子:`path.startswith("api")` 返回 `False`,因为字符串以 `/` 开头,不是以 `a` 开头。`startswith` 是严格的前缀匹配,从第一个字符开始比较。 `startswith` 可以接受一个**元组**作为参数,检查是否以其中任意一个开头: ``` filename = "photo.jpg" print(filename.startswith((".jpg", ".png", ".gif"))) # True print(filename.startswith((".txt", ".md"))) # False ``` 这个元组参数的特性很多人不知道,导致写出这样的代码: ``` if filename.startswith(".jpg") or filename.startswith(".png") or filename.startswith(".gif"): ... ``` 用元组可以写成一行: ``` if filename.startswith((".jpg", ".png", ".gif")): ... ``` ## **endswith:检查结尾** `endswith` 的签名是 `str.endswith(suffix[, start[, end]])`。它检查字符串在指定范围内是否以 `suffix` 结尾。 ``` filename = "report.pdf" print(filename.endswith(".pdf")) # True print(filename.endswith(".doc")) # False print(filename.endswith("pdf")) # True,没有点也匹配 ``` 注意最后一个例子:`filename.endswith("pdf")` 返回 `True`,因为 `"report.pdf"` 确实以 `"pdf"` 结尾。`endswith` 不关心你有没有写点,它只做字符串匹配。 这可能会带来一个隐患:如果你写 `filename.endswith("pdf")`,那么 `"notpdf"` 也会匹配,因为它以 `"pdf"` 结尾。通常我们应该写 `filename.endswith(".pdf")`,确保匹配的是扩展名,而不是文件名中随便出现的 `pdf`。 `endswith` 同样支持元组参数: ``` filename = "archive.tar.gz" print(filename.endswith((".gz", ".zip", ".rar"))) # True print(filename.endswith((".txt", ".md"))) # False ``` ## **坑一:把 startswith 和 endswith 用反** 这就是小刘踩的坑。前缀检查用了 `endswith`,后缀检查用了 `startswith`。 ``` # 错误写法 if path.endswith("/api/"): # 检查开头却用了 endswith handle_api(path) if path.startswith(".json"): # 检查结尾却用了 startswith serve_json(path) ``` 这种错误不会报错,因为 `endswith` 和 `startswith` 都返回布尔值,语法上完全合法。程序会继续跑,只是逻辑全错了。 正确的写法: ``` if path.startswith("/api/"): handle_api(path) if path.endswith(".json"): serve_json(path) ``` 防坑的方法很简单:记住 `start` 是“开始”,`end` 是“结束”。`startswith` 看开头,`endswith` 看结尾。名字本身就是提示。 ## **坑二:以为 startswith 会匹配任意位置** 有些人把 `startswith` 和 `in` 搞混,以为 `startswith` 是“包含”的意思。 ``` s = "hello world" print(s.startswith("world")) # False print("world" in s) # True ``` `startswith` 只检查开头,不检查中间。如果你想检查子串是否出现在任意位置,用 `in`。 同样,`endswith` 只检查结尾: ``` s = "hello world" print(s.endswith("hello")) # False print("hello" in s) # True ``` ## **坑三:start 和 end 参数用错** `startswith` 和 `endswith` 都支持可选的 `start` 和 `end` 参数,用来限定检查范围。 ``` s = "hello world" print(s.startswith("world", 6)) # True,从索引 6 开始,以 "world" 开头 print(s.endswith("hello", 0, 5)) # True,在 [0, 5) 范围内,以 "hello" 结尾 ``` 注意 `end` 参数是开区间,不包含 `end` 位置。 ``` s = "hello" print(s.endswith("o", 0, 4)) # False,因为 end=4 不包含索引 4 print(s.endswith("o", 0, 5)) # True ``` 这个细节容易搞混,但和切片的行为一致。 ## **坑四:用 or 连接多个 startswith 调用** 前面提到过,`startswith` 和 `endswith` 都支持元组参数。但很多人不知道,写成多个 `or`: ``` if filename.startswith(".jpg") or filename.startswith(".png") or filename.startswith(".gif"): ... ``` 这样写逻辑是对的,但很啰嗦,而且每次都要重复 `filename.startswith`。用元组更简洁: ``` if filename.startswith((".jpg", ".png", ".gif")): ... ``` 元组参数不仅更短,而且性能更好。因为只需要调用一次方法,而不是三次。 ## **坑五:大小写敏感** `startswith` 和 `endswith` 是大小写敏感的。 ``` filename = "Photo.JPG" print(filename.endswith(".jpg")) # False print(filename.endswith(".JPG")) # True ``` 如果要做大小写不敏感的匹配,需要先转换: ``` filename.lower().endswith(".jpg") # True ``` 或者用正则表达式加 `re.IGNORECASE`。 ## **坑六:路径分隔符在不同系统上不一样** 如果你在用 `startswith` 或 `endswith` 判断路径,要小心不同操作系统的分隔符。 ``` path = "C:\\Users\\Alice\\file.txt" # Windows print(path.endswith(".txt")) # True print(path.endswith("\\file.txt")) # True print(path.endswith("/file.txt")) # False ``` Windows 用 `\`,Linux/macOS 用 `/`。如果你的代码需要跨平台,最好用 `os.path` 或 `pathlib` 来处理路径,而不是直接用字符串方法。 ``` from pathlib import Path p = Path("file.txt") print(p.suffix == ".txt") # True ``` ## **性能:startswith 和 endswith 非常快** `startswith` 和 `endswith` 都是 O(k) 操作,k 是前缀或后缀的长度。它们不遍历整个字符串,只比较开头或结尾的固定长度。所以无论字符串多长,只要前缀/后缀长度固定,速度都很快。 ``` import timeit long_str = "a" * 1000000 + ".txt" t1 = timeit.timeit(lambda: long_str.endswith(".txt"), number=1000000) t2 = timeit.timeit(lambda: long_str[-4:] == ".txt", number=1000000) print(f"endswith: {t1:.4f}") print(f"切片比较: {t2:.4f}") ``` 在我的机器上,`endswith` 大约 0.05 秒,切片比较大约 0.08 秒。`endswith` 略快,因为它是 C 实现的,不需要创建切片对象。 ## **什么时候用 startswith,什么时候用 endswith?** 判断标准很清晰: **检查开头**:用 `startswith`。比如 URL 协议(`http://`、`https://`)、路径前缀(`/api/`)、命令前缀(`git `)。 **检查结尾**:用 `endswith`。比如文件扩展名(`.py`、`.json`)、域名后缀(`.com`、`.org`)、字符串后缀(`;`、`}`)。 **检查是否包含**:用 `in`。`startswith` 和 `endswith` 都不检查中间。 **检查多个前缀/后缀**:用元组参数,不要写多个 `or`。 **大小写不敏感**:先 `lower()` 或 `upper()`,或者用正则。 ## **实际应用场景** **场景一:文件类型过滤** ``` def is_image(filename): return filename.lower().endswith((".jpg", ".jpeg", ".png", ".gif", ".webp")) ``` **场景二:URL 路由** ``` def route(path): if path.startswith("/api/"): return handle_api(path) elif path.startswith("/static/"): return serve_static(path) else: return serve_index(path) ``` **场景三:协议检查** ``` def is_secure(url): return url.startswith("https://") ``` **场景四:日志级别解析** ``` def is_error(line): return line.startswith("ERROR") or line.startswith("CRITICAL") ``` **场景五:字符串清理** ``` def strip_quotes(s): if s.startswith('"') and s.endswith('"'): return s[1:-1] return s ``` ## **回到小刘的故事** 小刘最终的代码是这样的: ``` def route(path): if path.startswith("/api/"): return handle_api(path) elif path.endswith(".json"): return serve_json(path) else: return serve_static(path) ``` `/api/users` 正确进入了 API 处理器,`data.json` 正确返回了 JSON 内容。他把两个方法换回来之后,一切正常。 他后来在团队文档里写了一条:“`startswith` 看开头,`endswith` 看结尾。名字就是提示:start 是开始,end 是结束。别搞反了,搞反了不报错,但逻辑全错。” 这个错误听起来很低级,但真到写代码的时候,特别是在连续写了很多行判断逻辑之后,手滑把两个方法写反是常有的事。更麻烦的是,它不会报错,只会让程序静默地走错分支。 记住那个操场上排队的比喻:`startswith` 问的是队头,`endswith` 问的是队尾。你想问哪一头,就用哪个方法。 字符串判断本身不难,难的是在快速编码时保持对方法名的敏感度。`startswith` 和 `endswith` 的区别,不在于功能有多复杂,而在于位置完全相反。用对了,代码清晰高效;用反了,程序静默出错。 小刘后来养成了一个习惯:写 `startswith` 或 `endswith` 的时候,嘴里默念一遍“开头还是结尾”,确认无误再敲回车。这个习惯花不了两秒钟,但能省下一下午的调试时间。