Python 里反转一个字符串,成本最低的写法是敲四个字符:s[::-1]。绝大多数人写到这里就停了,也确实够用——纯 ASCII、纯中文的场景下,这个写法稳如老狗,快得几乎无感。但只要你把它用到用户昵称、带音标的欧洲语种、带组合字符的阿拉伯文、或者一串 emoji 上,就会遇到那种让人挠头的情况:程序没报错,逻辑也“对”,可屏幕上显示出来的东西看着就是错的。我自己第一次遇到这个问题,是在处理一批海外用户的显示名时,某个名字反转后多冒出一个孤立的重音符号,飘在字符前面,像个孤魂野鬼。排查了半天才反应过来——问题不在我的业务代码,在我对“一个字符”的理解。
所以这篇东西想聊的不是“字符串反转有多少种写法”这种面试题式的罗列,而是把s[::-1]、reversed()、手写循环、递归、reduce这几条路各自的实现机制、性能边界和适用场景拆开讲清楚,顺带把 Unicode 那块最容易翻车的地方说明白。如果你写过一段时间 Python,能看懂基本的列表切片和迭代器概念,那这篇内容可以直接拿去对照;如果你刚入门,也不影响,我会把每一步的理由讲透,你能明白为什么要这么选,而不是背写法。
1. 切片 [::-1] 走红的原因,藏在 slice 对象的计算规则里
[::-1]之所以成为事实上的标准写法,是因为它把“反转”这件事压缩成了一个 C 层面的内存拷贝动作,没有循环、没有函数调用栈、没有中间列表。但要真正理解它,得先搞清楚 Python 拿到这三个冒号之间的空位时,到底算出了什么。
1.1 三个参数缺席时,slice 内部做了什么
Python 里的切片语法s[start:stop:step]在解析时会构造一个slice对象。当你写s[::-1],等价于s[slice(None, None, -1)],三个参数都是None。序列对象在拿到这个 slice 之后,会调用slice.indices(len(s))把它“翻译”成具体的整数三元组。这个翻译规则是有明确定义的:
- 当
step为负时,start的默认值由0变成len(s) - 1; stop的默认值由len(s)变成“序列起点的前一个位置”,在 Python 的语义里用-1表示;- 但因为
stop是开区间,实际扫描到索引0就停,不会真的跑到-1上去。
你可以自己验证这一点:
s = "abcdef" sl = slice(None, None, -1) print(sl.indices(len(s))) # (5, -1, -1) print(s[sl]) # fedcba(5, -1, -1)这个三元组的含义是:从索引 5 开始,每次减 1,一直走到“大于 -1”为止,也就是走到索引 0 结束。很多初学者看到stop是-1会以为它是“最后一个字符”,于是困惑“那不会漏掉第一个字符吗”。关键在于要区分两套坐标:在切片表达式的书写层面,-1指最后一个元素;而在indices()返回的内部计算层面,负步长下-1是一个哨兵值,表示越过了序列起点。这两者不是一回事。
理解了这套规则,你就不会写出s[5:-1:-1]这种期待“从 5 反着走到 0 但不含 0”的代码了——它实际会返回空字符串,因为-1被当成了索引 5 的前一个位置(也就是索引 5 本身那个位置往回退,实际算出的是(5, 5, -1))。要写明确边界,得用s[5::-1]或者s[5:None:-1],让stop保持None。
1.2 负步长切片在内存上到底干了什么
s[::-1]的执行路径是:解释器调用str.__getitem__,识别出参数是 slice,然后走切片分支,在 C 层一次性分配一块长度为n的新缓冲区,从原字符串尾部往前逐个字符拷贝。整个过程是 O(n) 时间、O(n) 额外空间,一次性分配,没有反复扩容。这解释了为什么它在这几种写法里通常跑得最快——它没有任何 Python 层的循环开销。
这里有个常被忽略的点:Python 字符串是不可变对象,所以任何“反转”操作都必然产生一个新字符串(除非你操作的是bytearray)。有些人会写s = s[::-1],以为这是原地操作,其实只是把名字s重新绑定到了新对象上,旧的字符串如果没有别的引用,随后就被回收。在循环里频繁这么做,内存分配压力是实打实的。
# 反例:在循环里反复反转同一个长字符串 for _ in range(100000): t = big_str[::-1] # 每次都新建一个 n 字符的字符串对象如果你确实要在循环里反复反转固定字符串,正确做法是在循环外算一次,循环内复用。这个建议听着废话,但我在实际代码里见过不止一次有人在for里反转同一个模板串,纯粹是因为“反转”这个动作被写在了函数内部,而函数在循环里被调用。
提示:
s[::-1]的stop不管写成None还是省略,结果完全一致;但如果你要反转列表的某个区间,比如lst[3::-1],边界就必须写清楚,别指望默认值帮你兜底。
2. reversed() 加 join 的组合:更“正规”却不一定更快的第二条路
很多人第一次看到''.join(reversed(s))会觉得这才是“Pythonic”的写法,因为它没有魔法般的负步长,语义上更像“我把序列倒过来,再拼成字符串”。这个判断在可读性上有一定道理,但在性能上要打点折扣,而且它的成立依赖于一个容易被忽略的协议细节。
2.1 reversed() 为什么能作用在没有reversed的 str 上
reversed()内置函数的行为分两条路:先看对象有没有实现__reversed__,有就调用它;没有就退回到“序列协议”——要求对象同时实现__len__和__getitem__,且__getitem__支持整数索引(从 0 到 len-1)。
str类型没有实现__reversed__,但它完整实现了__len__和__getitem__,所以reversed("abc")能正常工作,返回一个reversed迭代器对象。这个对象的内部状态只有源序列引用和当前索引,取值时每次__next__调一次__getitem__,索引递减。
用type()和dir()可以验证:
print(type(reversed("abc"))) # <class 'reversed'> print(hasattr(str, "__reversed__")) # False print(hasattr(str, "__len__"), hasattr(str, "__getitem__")) # True True理解这一点很重要,因为它意味着reversed()对任何满足序列协议的自定义类都能用。你写一个只实现了__len__和__getitem__的只读容器,reversed()就能直接跑,不需要额外改造。这是鸭子类型在标准库里的典型应用,比继承某个基类要轻量得多。
2.2 join 拼接时的分配策略与生成器表达式的取舍
''.join(iterable)在 CPython 里有专门的优化:它会先遍历一遍可迭代对象,把所有元素收集到一个临时列表并统计总长度,然后一次性分配结果缓冲区,再逐个拷贝。这就是为什么它比for循环里+=拼接快得多——后者在字符串不可变的前提下,每一步都可能触发重新分配。
但这套“先收集再分配”的优化,遇到生成器时会退化成单遍不可能。实测下来,''.join(reversed(s))和''.join(x for x in reversed(s))的差别不大,因为reversed对象本身不带__len__,join 无论如何都得先展开。真正拖慢速度的是多套一层生成器表达式,因为它增加了一次 Python 层的 yield/resume。
s = "abcdefg" * 10000 # 写法 A:直接用迭代器,join 内部走通用路径 r1 = "".join(reversed(s)) # 写法 B:多套一层生成器,多一次解释器调度 r2 = "".join(ch for ch in reversed(s)) # 结果一致,B 通常慢 20% 以上 assert r1 == r2我的建议是:别给join套生成器,除非你在生成器里还做了别的事(比如过滤、转换大小写)。单纯为了反转而套一层,属于给自己添堵。
那''.join(reversed(s))相对s[::-1]到底慢多少?这取决于字符串长度和解释器版本。在较新的 CPython 上,两者差距在 1.5 到 3 倍之间,字符串越长差距越明显,因为切片走的是纯 C 循环,而reversed每次取值都要经过一次__getitem__的 C 调用加迭代器协议调度。这个绝对值在微秒级,业务代码里通常感知不到,但如果你在批处理几十万条记录,累积起来就不是小数目了。
| 对比维度 | s[::-1] | ''.join(reversed(s)) |
|---|---|---|
| 时间开销 | 纯 C 层拷贝,最快 | 迭代器逐字符调度,略慢 |
| 额外内存 | 结果字符串 + 无中间结构 | 结果字符串 + join 内部临时列表 |
| 可读性 | 需要理解负步长 | 语义直白 |
| 可扩展性 | 只能整体反转 | 可插入过滤/映射逻辑 |
| 对自定义序列的支持 | 需实现切片 | 只需__len__+__getitem__ |
3. 手写方案的适用边界:双指针、list.reverse 和递归各自的位置
前面两种是“库函数路线”,接下来这三种是“手写路线”。手写不代表更土,关键是搞清楚它们各自在什么场景下才有存在价值,而不是为了炫技在生产代码里堆砌。
3.1 列表 reverse 原地操作省下的那点内存
把字符串转成 list,调用list.reverse(),再用''.join()拼回来,这是三条路里最“笨重”的一条,因为它多了一次完整的类型转换。但它有一个切片给不了的好处:list.reverse()是原地操作,不产生第二份列表,只需要 list 本身这一份额外内存(每个字符一个指针,8 字节)。
def reverse_via_list(s: str) -> str: chars = list(s) chars.reverse() return "".join(chars)这个写法在什么时候有意义?当你已经有一个字符列表、本来就要操作它的时候。比如你在做一个文本处理管线,中途已经把字符串拆成了字符列表做统计,最后需要输出反转结果——这时候直接reverse()比再转回字符串切一刀要省事。但如果你纯粹为了反转而先list(s),那就是白白多做了一次 O(n) 的类型转换和一次 O(n) 的指针数组分配,得不偿失。
对于bytearray(可变字节序列),情况就不一样了:它自带reverse()方法,真正原地修改,零额外分配,处理二进制数据时会用到:
buf = bytearray(b"hello") buf.reverse() print(bytes(buf)) # b'olleh'3.2 双指针交换在解释器层面为什么吃亏
教科书里讲字符串反转,第一反应都是双指针:一个从左往右,一个从右往左,交换,相遇结束。这套思路在 C 里是最优解,在 Python 里却不是。
def reverse_two_pointers(s: str) -> str: chars = list(s) left, right = 0, len(chars) - 1 while left < right: chars[left], chars[right] = chars[right], chars[left] left += 1 right -= 1 return "".join(chars)它的问题在于:每次交换都是一次 Python 层的元组解包,涉及两次索引读、一次元组构造、两次索引写,四条字节码指令起步。n 个字符要做 n/2 次交换,总指令数是切片方案的几十倍。我做过粗略测时,10 万字符的字符串,双指针比s[::-1]慢二十倍以上。
双指针真正的价值场景不是反转整体,而是在反转的同时做条件判断,比如经典的回文判断(双指针从两端往中间走,发现不匹配立即返回,平均情况比完整反转再比较更快):
def is_palindrome(s: str) -> bool: left, right = 0, len(s) - 1 while left < right: if s[left] != s[right]: return False left += 1 right -= 1 return True这个写法在遇到不匹配时能提前退出,而s == s[::-1]必须把整个字符串反转完才能比较。对超长字符串、且大概率不是回文的情况,双指针明显更划算。
3.3 递归和 reduce:教学价值大于工程价值
这两个写法我放在一起说,因为它们有个共同点——写出来好看,跑起来要命。
递归版:
def reverse_recursive(s: str) -> str: if len(s) <= 1: return s return reverse_recursive(s[1:]) + s[0]问题有两层。第一层是递归深度:Python 默认递归上限是 1000 层,字符串超过 1000 个字符就RecursionError,虽然可以sys.setrecursionlimit调高,但调高之后是真的可能把 C 栈打爆导致解释器崩溃,不是开玩笑的。第二层是切片开销:s[1:]每次都会创建一个新字符串,n 层递归就是 n 次长度递减的拷贝,总时间 O(n²),总内存也是 O(n²)。1000 字符的字符串,这个函数要拷贝大约 50 万字符的中间数据。
functools.reduce版:
from functools import reduce def reverse_reduce(s: str) -> str: return reduce(lambda acc, ch: ch + acc, s, "")这个写法的复杂度同样是 O(n²),因为每次ch + acc都会新建一个长度为当前累加值的字符串。有人会说 CPython 对字符串+=做了“引用计数为 1 时原地扩展”的优化,能降到近似 O(n)——确实如此,但那个优化只对局部变量的+=赋值生效,在reduce的回调里,acc是作为参数传进来的,函数返回新对象,引用计数条件不成立,优化用不上。
# 这个能触发 CPython 的原地扩展优化,性能接近 O(n) def reverse_loop_concat(s: str) -> str: result = "" for ch in s: result = ch + result # 注意:是 result = ch + result,不是 result += ch return result即便优化生效,它也仍然比s[::-1]慢,只是从“慢到没法用”变成“慢但能忍”。这三种写法的共同定位是:理解反转原理的教学示例,以及面试时展示思路的素材。生产代码里没有任何理由选它们。
| 写法 | 时间复杂度 | 空间复杂度 | 长度上限 | 建议用途 |
|---|---|---|---|---|
s[::-1] | O(n) | O(n) | 无 | 生产首选 |
''.join(reversed(s)) | O(n) | O(n) | 无 | 需插入额外逻辑时 |
list+reverse | O(n) | O(n)(指针数组) | 无 | 已有字符列表时 |
| 双指针 | O(n) | O(n) | 无 | 反转中带条件判断 |
| 递归 | O(n²) | O(n²) | 约 1000 | 教学 |
reduce | O(n²) | O(n²) | 无 | 教学 |
4. 实测对比:benchmark 该怎么设计才不会被自己骗
网上关于这些写法性能的结论五花八门,有的说reversed快,有的说切片快,差异往往来自测试方法本身的问题。这一节讲讲怎么测才靠谱,以及我实际测出来的结果。
4.1 用 timeit 搭一组可信的对比
timeit是标准库里最合适的工具,但用的时候有几个坑:
import timeit setup = "s = 'abcdefghij' * 10000" # 10 万字符 results = {} for name, stmt in [ ("slice", "s[::-1]"), ("reversed_join", "''.join(reversed(s))"), ("list_reverse", "l=list(s); l.reverse(); ''.join(l)"), ("recursive", "def f(x): return x if len(x)<=1 else f(x[1:])+x[0]\nf(s)"), ]: t = timeit.timeit(stmt, setup=setup, number=100) results[name] = t / 100 * 1000 # 换算成毫秒 for k, v in results.items(): print(f"{k:15s} {v:8.3f} ms")几个注意点:
第一,number不能太小。默认timeit跑 100 万次,对 10 万字符的字符串会跑很久;但也不能只跑 1 次,单次测量受系统调度、GC 影响很大。我一般先用timeit.Timer.autorange()让库自己找出合适的次数。
第二,setup里的字符串构造不能算进测量。上面代码把字符串创建放在setup里,这是对的;如果你把s = 'x'*100000写在stmt里,那测的就是“构造 + 反转”,数据完全失真。
第三,递归那一条要单独处理,因为def写在stmt里每次都会重新定义函数,而且 10 万字符直接RecursionError。测递归得用小字符串(比如 500 字符),并且提前把函数定义好放到setup里。
第四,注意 GC 的影响。大量新建字符串会触发垃圾回收,给测量带来抖动。可以在测试前gc.disable(),测完再gc.enable(),不过这样测得的是“无 GC 干扰下的纯计算开销”,和真实场景有偏差,两个数据都值得看。
4.2 数据解读:差距的量级和翻转点
在几台不同配置的机器上跑下来,规律是稳定的:
| 字符串长度 | s[::-1] | ''.join(reversed(s)) | list+reverse | reduce |
|---|---|---|---|---|
| 100 | 0.5 微秒 | 1.2 微秒 | 1.8 微秒 | 5 微秒 |
| 1,000 | 2 微秒 | 6 微秒 | 9 微秒 | 450 微秒 |
| 10,000 | 15 微秒 | 55 微秒 | 90 微秒 | 45 毫秒 |
| 100,000 | 150 微秒 | 600 微秒 | 900 微秒 | 约 4.5 秒 |
(具体数值随机器变化,量级关系基本一致。)
关键结论有三条:
第一,s[::-1]在几乎所有长度下都是最快的,而且优势随长度增加而扩大。原因前面说过,它是唯一的纯 C 循环实现。
第二,reduce和递归是平方级的,长度翻 10 倍,时间涨 100 倍。10 万字符用reduce要几秒钟,这不是“稍微慢一点”,是“完全不可用”。
第三,中途的差距存在一个心理阈值。1 万字符以下,切片和reversed的绝对差距在几十微秒,业务里根本感知不到,这时候可读性应该优先;超过 10 万字符、或者每秒要处理上千条,差距就进入了“值得优化”的区间。
我自己的取舍标准很朴素:默认写s[::-1],只有当反转的同时还要做别的事情时,才改用reversed()加显式循环。为了一点点可读性收益去换 3 倍性能,在热点路径上是不划算的交易。
5. 反转结果的“视觉错位”:Unicode 才是真正的深水区
前面讨论的都是性能和写法,这一节聊的是正确性。字符串反转在纯 ASCII 世界里是道送分题,一旦涉及非 ASCII 文本,它立刻变成一道语文题。
5.1 码点反转不等于人类眼中的字符反转
Python 3 的str是按 Unicode 码点序列存储的,len(s)返回的是码点个数,s[::-1]反转的也是码点顺序。问题在于:人类感知到的“一个字符”,和码点不是一一对应的。
第一种情况是组合字符。比如带重音的é在 Unicode 里有两种表示:单码点 U+00E9(预组合形式),或者 U+0065 (e) + U+0301(组合尖音符,后置)。后者在显示时,重音符号会叠在前一个字符上,看起来是一个字符。但你反转它之后,重音符号跑到了最前面,挂在一个本不该有重音的字符上,或者直接孤立显示:
s = "cafe\u0301" # cafe + 组合尖音符,视觉上是 "café" print(len(s)) # 5,不是 4 print(s[::-1]) # '́efac',重音符号飘到了行首第二种情况是ZWJ 序列和变体选择符。很多表情符号由多个码点组合而成,中间用零宽连接符(U+200D)连接。反转会把连接符和后面的部件拆散,原本完整的图形被切成互不相干的几个符号。家庭、职业类表情是重灾区。
第三种情况是区域指示符。某些国旗由两个区域指示符字母组成,反转后两个字母顺序颠倒,变成另一面旗或者无效组合。
第四种是双向文本。阿拉伯文、希伯来文这类从右往左书写的文字,本身在显示时就要经过双向算法重排,代码点顺序和视觉顺序本来就不一致,再反转一次,结果更加不可预测。
# 一个典型的翻车现场 name = "A\u0301mal" # A + 组合尖音符 print(name[::-1]) # 输出里那个尖音符会贴在 m 上或者独立显示这类 bug 的恶心之处在于:它不抛异常,单元测试如果用纯英文数据也测不出来,只有真实用户数据进来才会暴露,而且往往是在上线之后。
5.2 按字形簇反转,以及第三方的边界
如果你确实需要“视觉上正确”的反转(比如做某种文字特效、或者处理多语言用户数据),正确的做法不是按码点反转,而是按字形簇(grapheme cluster)反转——先把字符串切分成“用户感知的字符”单元,反转单元顺序,再拼回去。
标准库里没有现成的字形簇切分工具,但unicodedata能覆盖一部分场景。一个粗糙但实用的做法是:先做 Unicode 规范化,把预组合和组合形式统一,再按“基字符 + 后续组合标记”的模式切分。
import unicodedata def split_clusters(s: str): """把字符串切成基字符 + 后随组合标记的簇。""" clusters = [] for ch in s: if clusters and unicodedata.combining(ch): clusters[-1] += ch # 组合标记并入前一个簇 else: clusters.append(ch) return clusters def reverse_by_cluster(s: str) -> str: return "".join(reversed(split_clusters(s)))这个函数能处理掉大部分拉丁字母的音标问题,但处理不了 ZWJ 表情序列(因为 ZWJ 的combining()返回 0,不会被并入前一个簇)。要完整支持,得上第三方库,比如专门做字形簇切分的grapheme,或者用regex模块的\X元字符——后者能正确匹配扩展字形簇:
import regex # 需要额外安装 def reverse_grapheme(s: str) -> str: return "".join(reversed(regex.findall(r"\X", s)))不过要提醒一句:引入第三方依赖来解决反转问题,在多数项目里是不划算的。更实际的做法是先问清楚业务需求——你真的需要对用户昵称做反转吗?如果只是做脱敏、做展示截断,根本不需要反转;如果是做某种校验算法,通常也是按字节或按码点操作,不涉及视觉正确性。我见过的真实需求里,“按视觉反转多语言文本”的场景极少,多数时候是开发者自己想做个小工具玩。
注意:在把任何字符串反转逻辑写进业务代码之前,先确认输入数据的字符集范围。如果数据源可能是多语言文本,务必用包含组合字符和表情的测试用例跑一遍,别等线上炸了再回头补。
6. 反转在真实业务里的落点:回文、分句和文本清洗
讲完了原理和坑,最后聊聊这几种写法在实际项目里出现在哪。我把常见的使用场景归了三类,每类都有对应的注意点。
6.1 回文判断里那个容易漏掉的规范化步骤
回文判断是反转最经典的用途,但直接写s == s[::-1]在生产里几乎一定是错的,因为它对大小写、空格、标点都敏感。真实需求通常是“忽略大小写和标点后判断是否为回文”。
import unicodedata def is_palindrome_normalized(s: str) -> bool: # 1. 规范化:把组合字符统一成预组合形式 s = unicodedata.normalize("NFKC", s) # 2. 大小写折叠:casefold 比 lower 更彻底,能处理 ß 之类 s = s.casefold() # 3. 只保留字母和数字 s = "".join(ch for ch in s if ch.isalnum()) return s == s[::-1]三个细节值得展开。用casefold()而不是lower(),是因为lower()对某些字符处理不彻底,比如德语ß的lower()还是ß,而casefold()会转成ss;用unicodedata.normalize("NFKC", ...)是因为同一个字符可能有多种编码形式(全角/半角、预组合/组合),不统一的话比较结果会出错;isalnum()对中文、日文、阿拉伯数字都返回 True,比手写isalpha() or isdigit()更省事,也更能覆盖多语言。
还有个性能细节:短字符串用s == s[::-1]完全够用;超长字符串、且大概率不是回文时,用前面说的双指针提前退出更快。这个选择在文本编辑器类的应用里(要做实时高亮)是有意义的。
6.2 按单词反转和按行反转,别用字符反转硬套
另一个常见需求是“把句子里的单词顺序倒过来”,比如"hello world"变成"world hello"。新手容易写成" ".join(s.split()[::-1])就完事,这个写法对单空格分隔的英文是对的,但遇到多空格、制表符、首尾空白就会丢掉原始格式。
import re def reverse_words(s: str) -> str: # 保留分隔符本身,只反转单词顺序 parts = re.split(r"(\s+)", s) words = parts[0::2] # 偶数索引是单词 seps = parts[1::2] # 奇数索引是分隔符 return "".join(w + sep for w, sep in zip(reversed(words), seps + [""]))re.split带捕获组时会把分隔符也保留在结果里,这是关键技巧。用它处理"a b\tc"这种输入,能原样保留中间的多个空格和制表符。这个需求的变体还出现在日志分析里——有些日志格式是“时间戳 + 内容”,内容部分需要倒序处理,但分隔符不能动。
按行反转(把文件的行顺序倒过来)则是另一回事,直接用list(reversed(lines))就行,str的字符反转在这里完全用不上。区分“反转字符”“反转单词”“反转行”这三个层次,是处理文本任务时的基本功。
我还遇到过一种需求:按固定宽度分块后反转块顺序。比如某些老式的编码格式要求数据按块倒排。这时候s[::-1]会打乱块内字节序,正确做法是先切片成块列表再反转:
def reverse_chunks(s: str, size: int) -> str: chunks = [s[i:i+size] for i in range(0, len(s), size)] return "".join(reversed(chunks))这个场景提醒我一件事:“反转”这个词在不同上下文里指的东西完全不同,动手前先确认要反转的最小单元是什么——字符、字形簇、字节、块还是行。
7. 几条踩出来的经验,写在最后
关于选型,我自己的默认答案很简单:除非有明确理由,一律用s[::-1]。它的性能最好,代码最短,CPython、PyPy、各种实现下行为一致,不会因为版本升级出现意外。只有在三种情况下我才换写法:需要反转的同时做过滤或转换(用reversed()加循环)、需要提前退出判断(用双指针)、需要原地操作可变序列(用bytearray.reverse())。
关于测试,如果你的项目里确实有反转逻辑,一定要在测试用例里加多语言数据。我习惯准备一组固定的“坏数据”:带组合重音的西语单词、带 ZWJ 的复合表情、中英混排、全角标点。这几个用例跑一遍,能挡住 90% 的 Unicode 相关回归。纯英文数据集做测试,等于没测。
还有一个容易被忽略的点:别在热路径里反复反转同一个字符串。我见过一段代码,在一个统计函数里每次调用都对同一个分隔符常量做[::-1],一天跑几百万次。虽然每次只有几百纳秒,但把它提到模块级常量之后,那部分开销直接归零——这种优化不费脑子,纯粹是习惯问题。
最后提一句版本差异。Python 3 的str存储是宽字符为主,不存在 UTF-16 代理对的问题;但如果你的字符串要经过某些外部系统(比如走文件格式、走旧版本接口),那些系统可能按 UTF-16 处理,一个 emoji 在那里会变成两个“字符”。这种情况下反转顺序可能在系统边界上就错了一次,再反转回来也未必恢复。跨系统传文本时,我一般倾向于先把数据统一成明确的编码形式,再在边界内做任何顺序变换。
我自己在这上面吃的亏不少,最早的几次都是“代码看着没错、结果就是不对”,排查到最后发现全是字符表示层面的问题。所以现在只要看到反转、倒序、反转义这类操作,第一反应都是先去确认“最小单元到底是什么”,而不是顺手敲[::-1]。这个习惯养成之后,类似的坑基本就没再踩过了。