Skip to content

zhang-hong-yang/Crazy-Python

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

疯狂的Python! 🐍

一些有趣的鲜为人知的Python特性集合.

WTFPL

Join the chat at https://gitter.im/Crazy-Python/Lobby

本文翻译自What the f*ck Python!

本文全文为意译,若有错误,请联系作者

Python作为一个设计优美的交互式脚本语言,提供了许多人性化的语法。但是也因为这个原因,有些Python的代码片段并不会按照用户想象的那样运行。

这篇文章就让我们总结一下那些Python里反直觉的代码片段,并且深入研究一下其中的运行原理。

下面的某些例子可能并不像是标题说的那样....嗯....疯狂(WTFs),但是它们仍旧会揭示一些你从来没有意识到的Python的语言特性。

我发现这是一种很好的学习编程语言内部原理的方法,我相信你会对这些东西感兴趣的!

如果你已经写了很久的Python代码,你可以把下面的这些例子当做一个挑战,试一试自己能不能在第一次就做对。也许你会感觉某些例子很熟悉,希望这些例子会勾起你通过自己的努力填上这些坑时的成就感。:sweat_smile:

好了,那么我们开始吧!

Table of Contents

示例结构说明

下面是例子里所使用的结构说明:

▶ 这是一个标题 *

首先是例子的标题,如果某个标题后面带有星号,说明这一段是最新的一个版本加上的。

# 介绍会在这里写一些初始化代码
# 为下面的神奇时刻做准备...

Output (Python version):

>>> python语句执行某个命令
一些神奇的输出

(可选): 有可能会介绍一下输出的内容

💡 解释:

  • 简短的介绍发生了什么和为什么会产生这些输出。
    写一些初始化代码
    Output:
    >>> 触发相应代码 # 这些代码会揭示为何会有上方那些神奇的输出内容

注意: 所有的例子都是在 Python 3.5.2 环境下测试通过,理论上如果没有特殊声明,可以在所有的Python版本下运行。

使用方法

在我看来,为了充分的利用这个仓库里的所有例子,最好的办法就是按照顺序把每个例子挨个看一遍:

  • 仔细阅读每个例子的初始化代码。如果你是一个经验丰富的Python程序员,那么大部分时候你都可以知道初始化代码执行后具体会发生什么。
  • 阅读输出结果并且,
    • 检查输出结果是否和你想的一样
    • 确认你是否知道产生这种结果背后的原理,
      • 如果不知道,那么请仔细阅读解释章节(如果你看完解释还是不懂的话,别犹豫,提交一个 issue 吧)
      • 如果知道,那么给自己点个赞,继续看下一个例子

👀 例子

第一章: 撕裂大脑

▶ 善变的字符串 *

1.

>>> a = "crazy_python"
>>> id(a)
2387669241224
>>> id("crazy" + "_" + "python") # 注意这两个字符串的id号是一样的
2387669241224

2.

>>> a = "crazy"
>>> b = "crazy"
>>> a is b
True

>>> a = "crazy!"
>>> b = "crazy!"
>>> a is b
False

>>> a, b = "crazy!", "crazy!"
>>> a is b
True

3.

>>> 'a' * 20 is 'aaaaaaaaaaaaaaaaaaaa'
True
>>> 'a' * 21 is 'aaaaaaaaaaaaaaaaaaaaa'
False

很不可思议,对吧?

💡 解释:

  • 上面这种特性是CPython的一种编译器优化技术(叫做字符串驻留技术)。就是如果将要创建的字符串在之前已经创建过并且驻留在了内存没有释放,那么CPython不会创建一个新的实例,而是会把指针指向已经存在于内存的老的字符串实例。
  • 如果一个字符串实例已经驻村在了内存中,那么后续所有跟它值一样的变量都可以将指针指向这个内存中的字符串实例(这样就会节省内存空间)
  • 在上面这几段程序代码中,有几段的字符串明显驻留在了内存中供多个变量引用。 决定一个字符串是否会驻留在内存中是由这个字符串的实现方法决定的。下面是一些判断字符串变量是否会驻留内存的方法:
    • 所有长度为1或者0的字符串,全部会驻留
    • 编译阶段(也就是把源代码编译成.pyc文件的阶段)的字符串被驻留在内存('crazy'会驻留在内存,但是''.join(['c','r','a','z','y'])就不会)
    • 如果字符串包含除了ASCII字符,数字和下划线以外的字符,那么这个字符串就不会驻留内存。这就是为什么'crazy!'赋值给a,b的时候得到的结果是False,因为!感叹号。CPython中对这个特点的具体实现请参考这里
  • 当变量ab在同一行赋值"crazy!"的时候,Python的解释器会创建一个新的字符串实例,然后把这两个变量同时指向这一个实例。但是如果你用两行实现赋值的话,Python解释器就不会知道已经有一个'crazy!'的字符串实例存在了(因为根据上面的规则,'crazy!'实例不会进行内存驻留供后面的语句引用)。这是一种专门在交互环境下编译器的一种优化方法。
  • 常量折叠是Python实现的一种窥孔优化(Peephole optimization)技术。意思就是'a'*20这个语句在编译的时候会自动替换成'aaaaaaaaaaaaaaaaaaaa'这个变量,用来减少运行时的运算时钟周期('a'*20需要多执行20次乘法运算)。常量折叠只在字符串长度小于等于20的时候发生(至于为什么?想想如果有一个'a*10**10'这样的语句,折叠后需要多大一个.pyc才能存下折叠后的字符串啊)。这里是实现这种技术的实现代码。

▶ 不变的哈希值

1.

some_dict = {}
some_dict[5.5] = "Ruby"
some_dict[5.0] = "JavaScript"
some_dict[5] = "Python"

Output:

>>> some_dict[5.5]
"Ruby"
>>> some_dict[5.0]
"Python"
>>> some_dict[5]
"Python"

"Python" 把之前的 "JavaScript" 覆盖掉了吗?

💡 解释

  • Python的字典结构是根据key值的哈希值判断两个key值是否相等的
  • 在Python中,不变对象(Immutable objects)的值如果一样,那么它们的哈希值肯定也一样
    >>> 5 == 5.0
    True
    >>> hash(5) == hash(5.0)
    True
    注意: 有些对象有不同的值,但是它们的哈希值也有可能是一样的(所谓的哈希冲突)
  • some_dict[5] = "Python"这句话执行的时候, "Python"这个字符串就会覆盖掉"JavaScript"这个值,因为在Python看来,55.0的哈希值是一样的,也就是说对于字典结构他们对应的是一个key值。
  • 在 StackOverflow 上面有一个回答对Python的这个特性解释的很棒。

▶ 说了要执行就一定会执行!

def some_func():
    try:
        return 'from_try'
    finally:
        return 'from_finally'

Output:

>>> some_func()
'from_finally'

💡 解释:

  • 当在try语句块中遇到return,break或者continue的时候,如果是"try...finlly"语句块,那么在执行完try语句块里的内容后,依然会执行finally语句块的内容。
  • return语句返回一个值的时候,那么因为在finally语句块中的return语句是最后执行的,那么返回的值就永远都是finally语句块中return语句返回的值。

▶ 鸠占鹊巢 *

class Crazy:
  pass

Output:

>>> Crazy() == Crazy() # 两个类实例是不同的
False
>>> Crazy() is Crazy() # 它们的id号也是不一样的
False
>>> hash(Crazy()) == hash(Crazy()) # 它们的哈希值按说也应该不一样
True
>>> id(Crazy()) == id(Crazy())
True

💡 解释:

  • id函数被调用的时候,Python创建了一个Crazy类实例,然后把这个实例传给了id函数。然后id函数返回这个实例的"id"号(实际上就是这个实例在内存中的地址),接着这个实例就被丢弃并且销毁了。

  • 当我们紧接着再做一遍上面的步骤的时候,Python会把同一块内存空间分配给第二次创建的Crazy实例。又因为在CPython中id函数使用的是内存地址作为返回值,所以就会出现两个对象实例的id号相同的情况了。

  • 所以,"对象的id是唯一的"这句话有一个前提条件是"在这个对象的生命周期内"。当这个对象在内存被销毁以后,其他的对象就可以占用它之前所用的内存空间产生一样的id号。

  • 但是为什么上面的例子里is操作符却产生了False? 我们再看一个例子。

    class Crazy(object):
      def __init__(self): print("I ")
      def __del__(self): print("D ")

    Output:

    >>> Crazy() is Crazy()
    I I D D
    >>> id(Crazy()) == id(Crazy())
    I D I D

    现在你可以发现, 不同的使用实例的方法会对实例销毁的时间产生影响。


▶ 神奇赋值法

some_string = "crazy"
some_dict = {}
for i, some_dict[i] in enumerate(some_string):
    pass

Output:

>>> some_dict # 一个带引索的字典被创建.
{0: 'c', 1: 'r', 2: 'a', 3: 'z', 4: 'y'}

💡 解释:

  • 一个 for 语句在Python语法中是这么定义的:

    for_stmt: 'for' exprlist 'in' testlist ':' suite ['else' ':' suite]
    

    exprlist 是一组被赋值的变量. 这就等于说这组变量在每次迭代开始的时候都会执行一次 {exprlist} = {next_value} 。 下面这个例子很好的解释了上面想要表达的意思:

    for i in range(4):
        print(i)
        i = 10

    Output:

    0
    1
    2
    3
    

    是不是以为上面的循环就会执行一次?

    💡 解释:

    • 在上面这个循环中,i=10这个赋值语句不会整个迭代过程产生任何影响。因为在每次迭代开始前,迭代函数(在这里是range(4))都会把下一次的值赋值给目标变量(在这里是i)。
  • 再来看上面的例子,enumerate(some_string)这个函数会在每次迭代的时候产生两个值,分别是i(一个从0开始的索引值)和一个字符(来自some_string的值)。然后这两个值会分别赋值给isome_dict[i]。把刚才的循环展开来看就像是下面这样:

    >>> i, some_dict[i] = (0, 'c')
    >>> i, some_dict[i] = (1, 'r')
    >>> i, some_dict[i] = (2, 'a')
    >>> i, some_dict[i] = (3, 'z')
    >>> i, some_dict[i] = (4, 'y')
    >>> some_dict

▶ 时间的误会

1.

array = [1, 8, 15]
g = (x for x in array if array.count(x) > 0)
array = [2, 8, 22]

Output:

>>> print(list(g))
[8]

2.

array_1 = [1,2,3,4]
g1 = (x for x in array_1)
array_1 = [1,2,3,4,5]

array_2 = [1,2,3,4]
g2 = (x for x in array_2)
array_2[:] = [1,2,3,4,5]

Output:

>>> print(list(g1))
[1,2,3,4]

>>> print(list(g2))
[1,2,3,4,5]

:blub: 解释

  • 生成器表达式中,in语句会在声明阶段求值,但是条件判断语句(在这里是array.count(x) > 0)会在真正的运行阶段(runtime)求值。
  • 在生成器运行之前,array已经被重新赋值为[2, 8, 22]了,所以这个时候再用count函数判断2,8,22在原列表中的数量,只有8是数量大于0的,所以最后这个生成器只返回了一个8是符合条件的。
  • 在第二部分中,g1,g2输出结果不同,是因为对array_1array_2的赋值方法不同导致的。
  • 在第一个例子中, array_1绑定了一个新的列表对象[1,2,3,4,5](可以理解成array_1的指针指向了一个新的内存地址),而且在这之前,in语句已经在声明时就为g1绑定好了旧的列表对象[1,2,3,4](这个就对象也没有随着新对象的赋值而销毁)。所以这时候g1array_1是指向不同的对象地址的。
  • 在第二个例子中,由于切片化的赋值,array_2并没有绑定(指向)新对象,而是将旧的对象[1,2,3,4]更新(也就是说旧对象的内存地址被新对象占用了)成了新的对象[1,2,3,4,5]。所以,g2array_2依旧同时指向一个地址,所以都更新成了新对象[1,2,3,4,5]

▶ 特殊的数字们

下面这个例子在网上非常的流行。

>>> a = 256
>>> b = 256
>>> a is b
True

>>> a = 257
>>> b = 257
>>> a is b
False

>>> a = 257; b = 257
>>> a is b
True

:blub: 解释:

is==的区别

  • is 操作符会检查两边的操作数是否引用的是同一个对象(也就是说,会检查两个操作数的id号是否匹配)。
  • == 操作符会比较两个操作数的值是否一样。
  • 所以说is是比较引用地址是否相同,==是比较值是否相同。下面的例子解释的比较清楚,
    >>> [] == []
    True
    >>> [] is [] # 这里的两个空list分配了不同的内存地址
    False

256 是一个已经存在于内存的对象 但是 257 不是

当你启动一个Python解释器的时候,数字-5256就会自动加载进内存。 这些数字都是一些比较常用的数字,所以Python解释器会把他们提前准备好以备以后使用。

下面这段话摘抄自 https://docs.python.org/3/c-api/long.html (已经翻译为中文)

The current implementation keeps an array of integer objects for all integers between -5 and 256, when you create an int in that range you just get back a reference to the existing object. So it should be possible to change the value of 1. I suspect the behavior of Python, in this case, is undefined.:-)

当前的实现方法是,维护一个从-5到256的整数数组,当你使用其中某一个数字的时候,系统会自动为你引用到已经存在的对象上去。我认为应该让它可以改变数字1的值。不过就现在来说,Python还没有这个功能。:-)

>>> id(256)
10922528
>>> a = 256
>>> b = 256
>>> id(a)
10922528
>>> id(b)
10922528
>>> id(257)
140084850247312
>>> x = 257
>>> y = 257
>>> id(x)
140084850247440
>>> id(y)
140084850247344

Python解释器在执行y = 257的时候还不能意识到我们之前已经创建过了一个值为257的对象,所以它又在内存创建了一个新的对象。

ab变量在同一行赋值并且所赋值相等时,它们会引用到同一个对象

>>> a, b = 257, 257
>>> id(a)
140640774013296
>>> id(b)
140640774013296
>>> a = 257
>>> b = 257
>>> id(a)
140640774013392
>>> id(b)
140640774013488
  • 当a和b在同一行被赋值为257的时候, Python解释器会创建一个新的对象,然后把这两个变量同时引用到这个新的对象。如果你分开两行赋值两个变量,那么解释器不会“知道”之前自己已经创建过一个同样的257对象了。
  • 这是一种专门针对交互式解释器环境的优化机制。 当你在控制面板中敲入两行命令的时候,这两行命令是分开编译的,所以他们也会单独进行优化。如果你准备把这个例子(两行分别赋值的例子)写进.py文件然后进行测试,那么你会发现结果跟写在一行是一样的,因为文件里的代码是一次性编译的。

▶ 三子棋之一步取胜法

# 首先先来初始化一个1*3的一维数组
row = [""]*3 #row i['', '', '']
# 然后再用二维数组模拟一个3*3的棋盘
board = [row]*3

Output:

>>> board
[['', '', ''], ['', '', ''], ['', '', '']]
>>> board[0]
['', '', '']
>>> board[0][0]
''
>>> board[0][0] = "X"
>>> board
[['X', '', ''], ['X', '', ''], ['X', '', '']]

我们只赋值了一个“X”为什么会出来三个呢?

💡 解释:

当我们初始化row变量的时候,下图显示的是内存中的变化

image

接着当变量board通过[row]*3初始化后,下图显示了内存的变化(其实最终每一个变量board[0],board[1],board[2]都引用了同一个row对象的内存地址)

image

我们可以通过不使用row变量来阻止这种情况的发生

>>> board = [['']*3 for _ in range(3)]
>>> board[0][0] = "X"
>>> board
[['X', '', ''], ['', '', ''], ['', '', '']]

▶ 没脑子的函数

funcs = []
results = []
for x in range(7):
    def some_func():
        return x
    funcs.append(some_func)
    results.append(some_func())

funcs_results = [func() for func in funcs]

Output:

>>> results
[0, 1, 2, 3, 4, 5, 6]
>>> funcs_results
[6, 6, 6, 6, 6, 6, 6]

虽然我们每次把some_func函数加入到funcs列表里的时候x都不一样,但是funcs列表里的所有函数都返回了6.

//下面这段代码也是这样

>>> powers_of_x = [lambda x: x**i for i in range(10)]
>>> [f(2) for f in powers_of_x]
[512, 512, 512, 512, 512, 512, 512, 512, 512, 512]

💡 解释

  • 当我们在一个循环中定义一个函数,并且在函数体中用了循环中的变量时,这个函数只会绑定这个变量本身,并不会绑定当前变量循环到的值。所以最终所有在循环中定义的函数都会使用循环变量最后的值做计算。

  • 如果你想实现心中想的那种效果,可以把循环变量当做一个参数传递进函数体。**为什么这样可以呢?**因为这样在函数作用域内会重新定义一个变量,不是循环里面的那个变量了。

    funcs = []
    for x in range(7):
        def some_func(x=x):
            return x
        funcs.append(some_func)

    Output:

    >>> funcs_results = [func() for func in funcs]
    >>> funcs_results
    [0, 1, 2, 3, 4, 5, 6]

is not ... 并不是 is (not ...)

>>> 'something' is not None
True
>>> 'something' is (not None)
False

💡 解释

  • is not 是一个单独的二元运算符, 和分开使用的isnot作用是不同的。
  • is not 只有在两边的操作数相同时(id相同)结果才为False,否则为True

▶ 尾部的逗号

Output:

>>> def f(x, y,):
...     print(x, y)
...
>>> def g(x=4, y=5,):
...     print(x, y)
...
>>> def h(x, **kwargs,):
  File "<stdin>", line 1
    def h(x, **kwargs,):
                     ^
SyntaxError: invalid syntax
>>> def h(*args,):
  File "<stdin>", line 1
    def h(*args,):
                ^
SyntaxError: invalid syntax

💡 解释:

  • 末尾的逗号在函数参数列表最后并不总是合法的
  • 在Python中,参数列表里,有一部分使用前导逗号分隔的,有一部分是用后导逗号分隔的(比如**kwargs这种参数用前导逗号分隔,正常参数x用后导逗号分隔)。而这种情况就会导致有些参数列表里的逗号前后都没有用到,就会产生冲突导致编译失败。
  • 注意 这种尾部逗号的问题已经在Python 3.6中被修复了。然后这里有对各种尾部逗号用法的讨论。

▶ 最后一个反斜杠

Output:

>>> print("\\ C:\\")
\ C:\
>>> print(r"\ C:")
\ C:
>>> print(r"\ C:\")

    File "<stdin>", line 1
      print(r"\ C:\")
                     ^
SyntaxError: EOL while scanning string literal

💡 解释

  • 如果字符串前面声明了r,说明后面紧跟着的是一个原始字符串,反斜杠在这种字符串中是没有特殊意义的
    >>> print(repr(r"craz\"y"))
    'craz\\"y'
  • 解释器实际上是怎么做的呢,虽然看起来仅仅是改变了反斜杠的转义特性,实际上,它(反斜杠)会把自己和紧跟着自己的下一个字符一起传入到解释器,用来供解释器做判断和转换。这也就是为什么当反斜杠在最后一个字符的时候会报错。

▶ 纠结的not

x = True
y = False

Output:

>>> not x == y
True
>>> x == not y
  File "<input>", line 1
    x == not y
           ^
SyntaxError: invalid syntax

💡 解释:

  • 操作符的优先级会影响表达式的计算顺序,并且在Python里,==操作符的优先级要高于not操作符。
  • 所以not x == y等于 not (x == y),又等于not (True == False),最终计算结果就会是True
  • 但是x == not y会报错是因为这个表达式可以等价于(x == not) y,而不是我们第一眼认为的x == (not y)

▶ 只剩一半的三引号

Output:

>>> print('crazypython''')
wtfpython
>>> print("crazypython""")
wtfpython
>>> # 下面的语句将会产生语法错误
>>> # print('''crazypython')
>>> # print("""crazypython")

💡 解释:

  • Python支持隐试的字符串连接,比如下面这样,
    >>> print("crazy" "python")
    crazypython
    >>> print("crazy" "") # or "crazy"""
    crazy
    
  • 在Python中,'''""" 也是一种字符串界定符,所以如果Python解释器发现了其中一个,那么就会一直在后面找对称的另一个界定符,这也就是为什么上面例子里注释掉的语句会有语法错误,因为解释器在后面找不到和前面'''"""配对的界定符。

▶ 消失的午夜零点

from datetime import datetime

midnight = datetime(2018, 1, 1, 0, 0)
midnight_time = midnight.time()

noon = datetime(2018, 1, 1, 12, 0)
noon_time = noon.time()

if midnight_time:
    print("Time at midnight is", midnight_time)

if noon_time:
    print("Time at noon is", noon_time)

Output:

('Time at noon is', datetime.time(12, 0))

午夜时间并没有被打印出来

💡 解释:

在Python 3.5以前, 对于被赋值为UTC零点的datetime.time对象的布尔值,会被认为是False。这是一个在用if obj:这种语句的时候经常会忽略的特性,所以我们在写这种if语句的时候,要注意判断obj是否等于null或者空。


▶ 站错队的布尔型

1.

# 一个计算列表里布尔型和Int型数量的例子
mixed_list = [False, 1.0, "some_string", 3, True, [], False]
integers_found_so_far = 0
booleans_found_so_far = 0

for item in mixed_list:
    if isinstance(item, int):
        integers_found_so_far += 1
    elif isinstance(item, bool):
        booleans_found_so_far += 1

Output:

>>> booleans_found_so_far
0
>>> integers_found_so_far
4

2.

another_dict = {}
another_dict[True] = "JavaScript"
another_dict[1] = "Ruby"
another_dict[1.0] = "Python"

Output:

>>> another_dict[True]
"Python"

3.

>>> some_bool = True
>>> "crazy"*some_bool
'crazy'
>>> some_bool = False
>>> "crazy"*some_bool
''

💡 解释:

  • 布尔型(Booleans)是 int类型的一个子类型(bool is instance of int in Python)

    >>> isinstance(True, int)
    True
    >>> isinstance(False, int)
    True
  • True的整形值是1False的整形值是0

    >>> True == 1 == 1.0 and False == 0 == 0.0
    True
  • StackOverFlow有针对这个问题背后原理的解答


第二章: 瞒天过海

▶ Skipping lines?


第三章: 注意地雷

▶ Modifying a dictionary while iterating over it


第四章: 隐藏的宝藏

This section contains few of the lesser-known interesting things about Python that most beginners like me are unaware of (well, not anymore).

▶ Okay Python, Can you make me fly? *


第五章: 杂项

+= is faster

贡献

欢迎任何补丁和修正!详细信息请看 CONTRIBUTING.md

如果想要参与讨论, 可以选择创建一个新的issue 或者加我的 QQ752602742

感谢

这个仓库翻译自 What the fu*k Python,迄今为止没谁需要感谢,感谢下原作者Satwik Kansal吧。

🎓 版权声明

WTFPL

©️ True1023

About

What the f*ck Python 中文翻译

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • Python 100.0%