Python高效编程之88条军规(2):你真的会格式化字符串吗?

时间:2022-07-25
本文章向大家介绍Python高效编程之88条军规(2):你真的会格式化字符串吗?,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

在微信公众号「极客起源」中输入595586,可学习全部的《Python高效编程之88条军规》系列文章。

在Python语言中,字符串有多种用途。可以用于在用户界面和命令行实用程序中显示消息;用于用于将数据写入文件和Socket;用于指定“异常”消息;用于调试程序。

格式化是将预定义的文本和数据组合成一条人类可读的消息的过程。Python具有4种不同的格式化字符串的方式,这4种方式有的是语言层面支持的,有的是通过标准库支持的。除其中一种方式外,其他的格式化方式都有严重的缺点,在使用时应该尽量避免这些缺陷。

1. C风格的字符串格式化方式

在Python语言中格式化字符串的最常见方法是使用%格式化运算符。预定义的文本模板以格式字符串的形式放在%运算符的左侧,要插入模板的数据在%运算符的右侧。这些数据可以是单个值,也可以是一个元组(不能是列表),表示将多个值插入模板。例如,在这里我使用%运算符将难以阅读的二进制和十六进制值转换为整数字符串:

a = 0b10111010
b = 0xc5c
print('二进制:%d, 十六进程:%d' % (a, b))

执行这段代码,会输出如下内容:

二进制:186, 十六进程:3164

格式字符串使用格式说明符(如%d)作为占位符,这些占位符将被%运算符右侧的值替换。格式说明符的语法来自C语言的printf函数,该函数已被Python(以及其他编程语言)继承。Python支持所有常用的printf函数格式化选项。例如%s,%x和%f格式说明符,以及对小数位,填充,填充和对齐的控制。许多不熟悉Python的程序员都以C风格的格式字符串开头,因为它们熟悉且易于使用。

但是使用C风格的格式化字符串方式,会带来如下4个问题:

问题1:

如果更改格式表达式右侧的元组中数据值的类型或顺序,可能会由于类型转换不兼容而抛出异常。例如,这个简单的格式表达式可以工作:

key = 'my_key'
value = 1.234
formatted = '%-10s = %.2f' % (key, value)
print(formatted)

执行这段代码,会输出如下内容:

my_key     = 1.23

但如何交换key和value的值,那将会抛出运行时异常:

key = 1.234
value = 'my_key'
formatted = '%-10s = %.2f' % (key, value)
print(formatted)

执行这段代码,会抛出如下异常:

Traceback (most recent call last):
  File "/python/format.py", line 12, in <module>
    formatted = '%-10s = %.2f' % (key, value)
TypeError: must be real number, not str

类似地,如果%右侧元组中值的顺序变化后,同样会抛出异常。

formatted = '%-10s = %.2f' % (key, value)

为了避免这种麻烦,你需要不断检查%运算符的两侧的数据类型是否匹配;此过程容易出错,因为每次修改代码,都必须人工检测数据类型是否匹配。

问题2:

C风格格式化表达式的第2个问题是当你需要在将值格式化为字符串之前对值进行小的修改时,它们将变得难以阅读,这是非常普遍的需求。在这里,我列出了厨房储藏室的内容,而没有进行内联更改:

pantry = [
    ('avocados', 1.25),
    ('bananas', 2.5),
    ('cherries', 15),
]
for i, (item, count) in enumerate(pantry):
    print('#%d: %-10s = %.2f' % (i, item, count))

执行这段代码,会输出如下的结果:

#0: avocados   = 1.25
#1: bananas    = 2.50
#2: cherries   = 15.00

现在,我对要格式化的值进行了一些修改,以便打印出更有用的信息。这导致格式化表达式中的元组变得太长,以至于需要将其分成多行,这会损害程序的可读性:

for i, (item, count) in enumerate(pantry):
    print('#%d: %-10s = %d' % (
        i + 1,
        item.title(),
        round(count)))

执行这段代码,会输出如下的内容:

#1: Avocados   = 1
#2: Bananas    = 2
#3: Cherries   = 15

问题3:

格式化表达式的第3个问题是如果要在格式字符串中多次使用相同的值,则必须在右侧重复该值多次:

template = '%s loves food. See %s cook.'
name = 'Max'
formatted = template % (name, name)
print(formatted)

执行这段代码,会输出如下的内容:

Max loves food. See Max cook.

如果需要对这些重复的值做一些小的修改,这将特别令人讨厌的事,而且非常容易出错。为了解决这个问题,推荐使用字典取代元组为格式化字符串提供数据。引用字典中值的方式是%(key),看下面的例子:

old_way = '%-10s , %.2f, %-8s' % (key, value,key)  # 重复指定key

new_way = '%(key)-10s , %(value).2f, %(key)-8s' % {
            'key': key, 'value': value}            # 只需要指定一次key 

print(old_way)
print(new_way)

执行这段代码,会输出如下的内容:

key1       , 1.13, key1    
key1       , 1.13, key1

我们可以看到,如果需要重复引用%右侧的值,在使用元组的情况下,需要重复指定这些值,如本例中的key。而使用字典,只需要指定一次key就可以了。

然后,使用字典格式化字符串会引入并加剧其他问题。对于上面的问题2,由于在格式化之前对值进行了小的修改,由于%运算符右侧存在键和冒号运算符,因此格式化表达式变得更长,并且在视觉上更加杂乱。在下面的代码中,我分别使用字典和不使用指点来格式化相同的字符串以说明此问题:

for i, (item, count) in enumerate(pantry):
    before = '#%d: %-10s = %d' % (
        i + 1,
        item.title(),
        round(count))

    after = '#%(loop)d: %(item)-10s = %(count)d' % {
        'loop': i + 1,
        'item': item.title(),
        'count': round(count),
    }

    assert before == after

问题4:

使用字典格式化字符串还会带了第4个问题,就是每个键必须至少指定两次:在格式说明符中指定一次,另一次是在字典中指定为键,如果字典值本身是一个变量,也需要再次指定。

soup = 'lentil'
formatted = 'Today's soup is %(soup)s.' % {'soup': soup}   # 这里再次指定了变量soup
print(formatted)

输出结果如下:

Today's soup is lentil.

除了重复字符之外,这种冗余还会导致使用字典的格式化表达式很长。这些表达式通常必须跨多行,格式字符串跨多行连接,并且字典赋值每个值只有一行用于格式化:

menu = {
    'soup': 'lentil',
    'oyster': 'kumamoto',
    'special': 'schnitzel',
}
template = ('Today's soup is %(soup)s, '
            'buy one get two %(oyster)s oysters, '
            'and our special entrée is %(special)s.')
formatted = template % menu
print(formatted)

输出结果如下:

Today's soup is lentil, buy one get two kumamoto oysters, and our special entrée is schnitzel.

由于格式化字符串很长,可能会跨多行,所以要想了解整个字符串想表达什么,你的眼镜必须上下左右来回移动,而且很容易忽略本应该发现的错误。那么是否有更好的格式化字符串的解决方案呢?请继续往下看:

2. 内建format函数与str.format方法

Python 3添加了对高级字符串格式化的支持,这种格式化方式比使用%运算符的C风格格式化字符串更具表现力。对于单独的值,可以通过格式化内建函数来访问此新功能。例如,下面的代码使用一些新选项(,用于千分位分隔符,使用^用于居中)来格式化值:

a = 1234.5678
formatted = format(a, ',.2f')
print(formatted)

b = 'my string'
formatted = format(b, '^20s')    # 居中显示字符串
print('*', formatted, '*')

运行结果如下:

1,234.57
*      my string       *

您可以通过调用字符串的format方法来格式化多个值。format方法使用{}作为占位符,而不是使用%d这样的C风格格式说明符。在默认情况下,格式化字符串中的占位符按着它们出现的顺序传递给format方法相应位置的占位符。

key = 'my_var'
value = 1.234

formatted = '{} = {}'.format(key, value)
print(formatted)

运行结果如下:

my_var = 1.234

每个占位符内可以在冒号(:)后面指定格式化说明符,用来指定将值转换为字符串的方式,代码如下:

formatted = '{:<10} = {:.2f}'.format(key, value)
print(formatted)

运行结果如下:

my_var      = 1.23

format方法的工作原理是将格式化说明符与值(上例中的format(value,'.2f'))一起传递给内建函数format。然后将 该函数的返回值替换对应的占位符。可以使用__format__方法针对每个类自定义格式化行为。

对于C风格的格式化字符串,需要对%运算符进行转换转义,也就是写两个%,以免被误认为是占位符。使用str.format方法,也需要对花括号进行转义。

print('%.2f%%' % 12.5)
print('{} replaces {{}}'.format(1.23))

输出结果如下:

12.50%
1.23 replaces {}

在花括号内还可以指定传递给format方法的参数的位置索引,以用于替换占位符。这允许在不更改format方法传入值顺序的情况下,更改格式化字符串中占位符的顺序。

formatted = '{1} = {0}'.format(key, value)
print(formatted)

输出结果如下所示:

1.234 = my_var

使用位置索引还有一个好处,就是在格式化字符串中要多次引用某个值时,只需要通过format方法传递一个值即可。在格式化字符串中可以使用同一个位置索引引用多次这个值。

formatted = '{0} loves food. See {0} cook.'.format(name)
print(formatted)

输出结果如下:

Max loves food. See Max cook.

不幸的是,format方法无法解决上面的问题2,所以在格式化之前需要对值进行小的修改时比较费劲(因为需要对齐参数的位置)。下面的代码是将%运算符和format方法在一起进行比较,其实同时同样不容易阅读。

for i, (item, count) in enumerate(pantry):
    old_style = '#%d: %-10s = %d' % (
        i + 1,
        item.title(),
        round(count))
    new_style = '#{}: {:<10s} = {}'.format(
        i + 1,
        item.title(),
        round(count))

    assert old_style == new_style

尽管format方法使用的格式化说明符还有更多高级选项,例如在占位符中使用字典键和列表索引的组合,以及将值强制转换为Unicode和repr字符串:

formatted = 'First letter is {menu[oyster][0]!r}'.format(
    menu=menu)
print(formatted)

运行结果如下:

First letter is 'k'

但是这些功能并不能帮助减少上述问题4中重复key的冗余性。例如,在这里,我将在C风格格式化表达式中使用字典的冗长性与将key参数传递给format方法的新样式进行了比较:

old_template = (
    'Today's soup is %(soup)s, '
    'buy one get two %(oyster)s oysters, '
    'and our special entrée is %(special)s.')
old_formatted = template % {
    'soup': 'lentil',
    'oyster': 'kumamoto',
    'special': 'schnitzel',
}

new_template = (
    'Today's soup is {soup}, '
    'buy one get two {oyster} oysters, '
    'and our special entrée is {special}.')
new_formatted = new_template.format(
    soup='lentil',
    oyster='kumamoto',
    special='schnitzel',
)
assert old_formatted == new_formatted

这种样式的噪音较小,因为它消除了词典中的一些引号和格式化说明符中的一些字符,但是并没有达到完美的程度。此外,在占位符中使用字典键和索引的高级功能仅提供了Python表达式功能的一小部分。这种缺乏表现力的局限性使得它从总体上破坏了format方法的价值。

考虑到这些缺点以及仍然存在C风格格式化表达式的问题(上面的问题2和问题4),我的建议是尽量避免使用str.format方法。了解格式化说明符(冒号之后的所有内容)中使用的新的迷你语言以及如何使用格式内置功能是非常重要的。

3. f-字符串

Python 3.6添加了插值格式化字符串(简称f字符串)来彻底解决这些问题。这种新的语言语法要求您以f字符作为格式字符串的前缀,这类似于字节字符串以b字符作为前缀,以及原始(未转义的)字符串以r字符作为前缀。

f-字符串将格式字符串的表现力发挥到极致,通过完全消除提供要格式化的键和值的冗余性,完全解决了问题4。它们通过允许您引用当前Python范围中的所有变量作为格式化表达式的一部分来实现这一点:

key = 'my_var'
value = 1.234

formatted = f'{key} = {value}'
print(formatted)

输出结果如下:

my_var = 1.234

格式化的内置迷你语言中的所有相同选项都可以在f-字符串内占位符后的冒号后面使用,也可以类似于str.format方法将值强制转换为Unicode和repr字符串:

formatted = f'{key!r:<10} = {value:.2f}'
print(formatted)

输出结果如下:

'my_var' = 1.23

在所有情况下,使用f-字符串进行格式化比使用带有%运算符和str.format方法的C风格格式化字符串进行格式化要短。在这里,我按照最短到最长的顺序显示了所有这些格式化方式,以便您可以轻松进行比较:

f_string = f'{key:<10} = {value:.2f}'

c_tuple  = '%-10s = %.2f' % (key, value)

str_args = '{:<10} = {:.2f}'.format(key, value)

str_kw   = '{key:<10} = {value:.2f}'.format(key=key,
                                          value=value)

c_dict   = '%(key)-10s = %(value).2f' % {'key': key,
                                       'value': value}

print(f'f_string:{f_string}')
print(f'c_tuple:{c_tuple}')
print(f'str_args:{str_args}')
print(f'str_kw:{str_kw}')
print(f'c_dict:{c_dict}')

输出结果如下:

f_string:my_var     = 1.23
c_tuple:my_var     = 1.23
str_args:my_var     = 1.23
str_kw:my_var     = 1.23
c_dict:my_var     = 1.23

f-字符串还可以将完整的Python表达式放在占位符括号内,通过对使用简明语法格式化的值进行小的修改,可以从根本上解决问题2。现在,使用C样式格式化和str.format方法花费多行的内容现在很容易放在一行上:

for i, (item, count) in enumerate(pantry):
    old_style = '#%d: %-10s = %d' % (
        i + 1,
        item.title(),
        round(count))

    new_style = '#{}: {:<10s} = {}'.format(
        i + 1,
        item.title(),
        round(count))

   f_string = f'#{i+1}: {item.title():<10s} = {round(count)}'

   assert old_style == new_style == f_string

当然,如果为了让代码更清晰,可以将f-字符串拆分为多行。即使比单行版本更长,也比其他任何多行方法都清晰得多:

for i, (item, count) in enumerate(pantry):
    print(f'#{i+1}: '

          f'{item.title():<10s} = '
          f'{round(count)}')

输出结果如下:

#1: Avocados   = 1
#2: Bananas    = 2
#3: Cherries   = 15

Python表达式也可以出现在格式化说明符选项中。例如,在这里我通过使用变量而不是将其硬编码为格式化字符串来指定要输出的浮点数位数:

places = 3
number = 1.23456
print(f'My number is {number:.{places}f}')

f-字符串可以让表达力,简洁性和清晰度结合在一起,使它们成为Python程序员最好的内置选项。每当您发现自己需要将值格式化为字符串时,都可以选择f-字符串作为替代。

总结:

1. 使用%运算符的C风格格式化字符串会遇到各种陷阱和冗长的问题;

2.str.format方法在其格式说明符迷你语言中引入了一些有用的概念,但在其他方面会重复C风格格式化字符串的错误,应避免使用;

3. f-字符串是用于将值格式化为字符串的新语法,解决了C风格格式化字符串最大的问题;

4. f-字符串简洁而强大,因为它们允许将任意Python表达式直接嵌入格式说明符中;