文档开发者中心
我的

收藏 | 27个Python设计和历史问题(上篇)

今天,我要分享一篇长文,选自 Python 的官方文档。它列举了 27 个设计及历史的问题,其中有些问题我曾经分享过,例如为什么使用显式的 self、浮点数的问题、len(x) 而非 x.len() 等等。大部分的回答很简略精要,适合在空闲之余翻阅。建议你先收藏起来,随时查看,温故知新。


目录

  • 为什么Python使用缩进来分组语句?

  • 为什么简单的算术运算得到奇怪的结果?

  • 为什么浮点计算不准确?

  • 为什么Python字符串是不可变的?

  • 为什么在方法定义和调用中显式使用“self”?

  • 为什么不能在表达式中赋值?

  • 为什么Python对某些功能(例如list.index())使用方法来实现,而其他功能(例如len(List))使用函数实现?

  • 为什么 join()是一个字符串方法而不是列表或元组方法?

  • 异常有多快?

  • 为什么Python中没有switch或case语句?

  • 难道不能在解释器中模拟线程,而非得依赖特定于操作系统的线程实现吗?

  • 为什么lambda表达式不能包含语句?

  • 可以将Python编译为机器码,C或其他语言吗?

  • Python如何管理内存?

  • 为什么CPython不使用更传统的垃圾回收方案?

  • CPython退出时为什么不释放所有内存?

  • 为什么有单独的元组和列表数据类型?

  • 列表是如何在CPython中实现的?

  • 字典是如何在CPython中实现的?

  • 为什么字典key必须是不可变的?

  • 为什么 list.sort() 没有返回排序列表?

  • 如何在Python中指定和实施接口规范?

  • 为什么没有goto?

  • 为什么原始字符串(r-strings)不能以反斜杠结尾?

  • 为什么Python没有属性赋值的“with”语句?

  • 为什么 if/while/def/class语句需要冒号?

  • 为什么Python在列表和元组的末尾允许使用逗号?

为什么使用缩进来分组语句?
Guido van Rossum 认为使用缩进进行分组非常优雅,并且大大提高了普通Python程序的清晰度。大多数人在一段时间后就学会并喜欢上这个功能。
由于没有开始/结束括号,因此解析器感知的分组与人类读者之间不会存在分歧。偶尔C程序员会遇到像这样的代码片段:
if (x <= y)
       x++;
       y--;
z++;
如果条件为真,则只执行 x++ 语句,但缩进会使你认为情况并非如此。即使是经验丰富的C程序员有时会长时间盯着它,想知道为什么即使 x > y , y 也在减少。
因为没有开始/结束括号,所以Python不太容易发生编码式冲突。在C中,括号可以放到许多不同的位置。如果您习惯于阅读和编写使用一种风格的代码,那么在阅读(或被要求编写)另一种风格时,您至少会感到有些不安。
许多编码风格将开始/结束括号单独放在一行上。这使得程序相当长,浪费了宝贵的屏幕空间,使得更难以对程序进行全面的了解。理想情况下,函数应该适合一个屏幕(例如,20--30行)。20行Python可以完成比20行C更多的工作。这不仅仅是由于缺少开始/结束括号 -- 缺少声明和高级数据类型也是其中的原因 -- 但缩进基于语法肯定有帮助。
为什么简单的算术运算得到奇怪的结果?
请看下一个问题。
为什么浮点计算不准确?
用户经常对这样的结果感到惊讶:
>>> 1.2 - 1.0
0.19999999999999996
并且认为这是 Python中的一个 bug。其实不是这样。这与 Python 关系不大,而与底层平台如何处理浮点数字关系更大。
CPython 中的 float 类型使用C语言的 double 类型进行存储。float 对象的值是以固定的精度(通常为 53 位)存储的二进制浮点数,由于 Python 使用 C 操作,而后者依赖于处理器中的硬件实现来执行浮点运算。这意味着就浮点运算而言,Python 的行为类似于许多流行的语言,包括 C 和 Java。
许多可以轻松地用十进制表示的数字不能用二进制浮点表示。例如,在输入以下语句后:
>>> x = 1.2
为 x 存储的值是与十进制的值 1.2 (非常接近) 的近似值,但不完全等于它。在典型的机器上,实际存储的值是:
1.0011001100110011001100110011001100110011001100110011 (binary)
它对应于十进制数值:
1.1999999999999999555910790149937383830547332763671875 (decimal)
典型的 53 位精度为 Python 浮点数提供了 15-16 位小数的精度。
要获得更完整的解释,请参阅 Python 教程中的 浮点算术 一章。
为什么Python字符串是不可变的?
有几个优点。
一个是性能:知道字符串是不可变的,意味着我们可以在创建时为它分配空间,并且存储需求是固定不变的。这也是元组和列表之间区别的原因之一。
另一个优点是,Python 中的字符串被视为与数字一样“基本”。任何动作都不会将值 8 更改为其他值,在 Python 中,任何动作都不会将字符串 '8' 更改为其他值。
为什么必须在方法定义和调用中显式使用“self”?
这个想法借鉴了 Modula-3 语言。出于多种原因它被证明是非常有用的。
首先,更明显的显示出,使用的是方法或实例属性而不是局部变量。阅读 self.x 或 self.meth() 可以清楚地表明,即使您不知道类的定义,也会使用实例变量或方法。在 C++ 中,可以通过缺少局部变量声明来判断(假设全局变量很少见或容易识别) —— 但是在 Python 中没有局部变量声明,所以必须查找类定义才能确定。一些 C++ 和 Java 编码标准要求实例属性具有 m_ 前缀,因此这种显式性在这些语言中仍然有用。
其次,这意味着如果要显式引用或从特定类调用该方法,不需要特殊语法。在 C++ 中,如果你想使用在派生类中重写基类中的方法,你必须使用 :: 运算符 -- 在 Python 中你可以编写 baseclass.methodname(self,)。这对于 init() 方法非常有用,特别是在派生类方法想要扩展同名的基类方法,而必须以某种方式调用基类方法时。
最后,它解决了变量赋值的语法问题:为了 Python 中的局部变量(根据定义!)在函数体中赋值的那些变量(并且没有明确声明为全局)赋值,就必须以某种方式告诉解释器一个赋值是为了分配一个实例变量而不是一个局部变量,它最好是通过语法实现的(出于效率原因)。C++ 通过声明来做到这一点,但是 Python 没有声明,仅仅为了这个目的而引入它们会很可惜。使用显式的 self.var 很好地解决了这个问题。类似地,对于使用实例变量,必须编写 self.var 意味着对方法内部的非限定名称的引用不必搜索实例的目录。换句话说,局部变量和实例变量存在于两个不同的命名空间中,您需要告诉 Python 使用哪个命名空间。
为什么不能在表达式中赋值?
许多习惯于C或Perl的人抱怨,他们想要使用C 的这个特性:
while (line = readline(f)) {
   // do something with line
}
但在Python中被强制写成这样:
while True:
   line = f.readline()
   if not line:
       break
   ...  # do something with line
不允许在 Python 表达式中赋值的原因是这些其他语言中常见的、很难发现的错误,是由这个结构引起的:
if (x = 0) {
   // error handling
}
else {
   // code that only works for nonzero x
}
错误是一个简单的错字:x = 0 ,将0赋给变量 x ,而比较 x == 0 肯定是可以预期的。
已经有许多替代方案提案。大多数是为了少打一些字的黑客方案,但使用任意或隐含的语法或关键词,并不符合语言变更提案的简单标准:它应该直观地向尚未被介绍到这一概念的人类读者提供正确的含义。
一个有趣的现象是,大多数有经验的Python程序员都认识到 while True 的习惯用法,也不太在意是否能在表达式构造中赋值; 只有新人表达了强烈的愿望希望将其添加到语言中。
有一种替代的拼写方式看起来很有吸引力,但通常不如'while True'解决方案可靠:
line = f.readline()
while line:
   ...  # do something with line...
   line = f.readline()
问题在于,如果你改变主意(例如你想把它改成 sys.stdin.readline() ),如何知道下一行。你必须记住改变程序中的两个地方 -- 第二次出现隐藏在循环的底部。
最好的方法是使用迭代器,这样能通过 for 语句来循环遍历对象。例如 file objects 支持迭代器协议,因此可以简单地写成:
for line in f:
   ...  # do something with line...
为什么Python对某些功能(例如list.index())使用方法来实现,而其他功能(例如len(List))使用函数实现?
正如Guido所说:
(a) 对于某些操作,前缀表示法比后缀更容易阅读 -- 前缀(和中缀!)运算在数学中有着悠久的传统,就像在视觉上帮助数学家思考问题的记法。比较一下我们将 x(a+b) 这样的公式改写为 xa+x*b 的容易程度,以及使用原始OO符号做相同事情的笨拙程度。
(b) 当读到写有len(X)的代码时,就知道它要求的是某件东西的长度。这告诉我们两件事:结果是一个整数,参数是某种容器。相反,当阅读x.len()时,必须已经知道x是某种实现接口的容器,或者是从具有标准len()的类继承的容器。当没有实现映射的类有get()或key()方法,或者不是文件的类有write()方法时,我们偶尔会感到困惑。
—https://mail.python.org/pipermail/python-3000/2006-November/004643.html
为什么 join()是一个字符串方法而不是列表或元组方法?
从Python 1.6开始,字符串变得更像其他标准类型,当添加方法时,这些方法提供的功能与始终使用String模块的函数时提供的功能相同。这些新方法中的大多数已被广泛接受,但似乎让一些程序员感到不舒服的一种方法是:
', '.join(['1', '2', '4', '8', '16'])
结果如下:
'1, 2, 4, 8, 16'
反对这种用法有两个常见的论点。
第一条是这样的:“使用字符串文本(String Constant)的方法看起来真的很难看”,答案是也许吧,但是字符串文本只是一个固定值。如果在绑定到字符串的名称上允许使用这些方法,则没有逻辑上的理由使其在文字上不可用。
第二个异议通常是这样的:“我实际上是在告诉序列使用字符串常量将其成员连接在一起”。遗憾的是并非如此。出于某种原因,把 split() 作为一个字符串方法似乎要容易得多,因为在这种情况下,很容易看到:
'1, 2, 4, 8, 16'.split(', ')
是对字符串文本的指令,用于返回由给定分隔符分隔的子字符串(或在默认情况下,返回任意空格)。
join() 是字符串方法,因为在使用该方法时,您告诉分隔符字符串去迭代一个字符串序列,并在相邻元素之间插入自身。此方法的参数可以是任何遵循序列规则的对象,包括您自己定义的任何新的类。对于字节和字节数组对象也有类似的方法。
异常有多快?
如果没有引发异常,则try/except块的效率极高。实际上捕获异常是昂贵的。在2.0之前的Python版本中,通常使用这个习惯用法:
try:
   value = mydict[key]
except KeyError:
   mydict[key] = getvalue(key)
   value = mydict[key]
只有当你期望dict在任何时候都有key时,这才有意义。如果不是这样的话,你就是应该这样编码:
if key in mydict:
   value = mydict[key]
else:
   value = mydict[key] = getvalue(key)
对于这种特定的情况,您还可以使用 value = dict.setdefault(key, getvalue(key)),但前提是调用 getvalue()足够便宜,因为在所有情况下都会对其进行评估。
为什么Python中没有switch或case语句?
你可以通过一系列 if… elif… elif… else.轻松完成这项工作。对于switch语句语法已经有了一些建议,但尚未就是否以及如何进行范围测试达成共识。有关完整的详细信息和当前状态,请参阅 PEP 275 。
对于需要从大量可能性中进行选择的情况,可以创建一个字典,将case 值映射到要调用的函数。例如:
def function_1(...):
   ...

functions = {'a': function_1,
            'b': function_2,
            'c': self.method_1, ...}

func = functions[value]
func()
对于对象调用方法,可以通过使用 getattr() 内置检索具有特定名称的方法来进一步简化:
def visit_a(self, ...):
   ...
...

def dispatch(self, value):
   method_name = 'visit_' + str(value)
   method = getattr(self, method_name)
   method()
建议对方法名使用前缀,例如本例中的 visit_ 。如果没有这样的前缀,如果值来自不受信任的源,攻击者将能够调用对象上的任何方法。
难道不能在解释器中模拟线程,而非得依赖特定于操作系统的线程实现吗?
答案1:不幸的是,解释器为每个Python堆栈帧推送至少一个C堆栈帧。此外,扩展可以随时回调Python。因此,一个完整的线程实现需要对C的线程支持。
答案2:幸运的是, Stackless Python 有一个完全重新设计的解释器循环,可以避免C堆栈。
为什么lambda表达式不包含语句?
Python的 lambda表达式不能包含语句,因为Python的语法框架不能处理嵌套在表达式内部的语句。然而,在Python中,这并不是一个严重的问题。与其他语言中添加功能的lambda表单不同,Python的 lambdas只是一种速记符号,如果您懒得定义函数的话。
函数已经是Python中的第一类对象,可以在本地范围内声明。因此,使用lambda而不是本地定义的函数的唯一优点是你不需要为函数创建一个名称 -- 这只是一个分配了函数对象(与lambda表达式生成的对象类型完全相同)的局部变量!
可以将Python编译为机器代码,C或其他语言吗?
Cython 将带有可选注释的Python修改版本编译到C扩展中。Nuitka 是一个将Python编译成 C++ 代码的新兴编译器,旨在支持完整的Python语言。要编译成Java,可以考虑 VOC 。
Python如何管理内存?
Python 内存管理的细节取决于实现。Python 的标准实现 CPython 使用引用计数来检测不可访问的对象,并使用另一种机制来收集引用循环,定期执行循环检测算法来查找不可访问的循环并删除所涉及的对象。gc 模块提供了执行垃圾回收、获取调试统计信息和优化收集器参数的函数。
但是,其他实现(如 Jython 或 PyPy ),)可以依赖不同的机制,如完全的垃圾回收器 。如果你的Python代码依赖于引用计数实现的行为,则这种差异可能会导致一些微妙的移植问题。
在一些Python实现中,以下代码(在CPython中工作的很好)可能会耗尽文件描述符:
for file in very_long_list_of_files:
   f = open(file)
   c = f.read(1)
实际上,使用CPython的引用计数和析构函数方案, 每个新赋值的 f 都会关闭前一个文件。然而,对于传统的GC,这些文件对象只能以不同的时间间隔(可能很长的时间间隔)被收集(和关闭)。
如果要编写可用于任何python实现的代码,则应显式关闭该文件或使用 with 语句;无论内存管理方案如何,这都有效:
for file in very_long_list_of_files:
   with open(file) as f:
       c = f.read(1)
为什么CPython不使用更传统的垃圾回收方案?
首先,这不是C标准特性,因此不能移植。(是的,我们知道Boehm GC库。它包含了 大多数 常见平台(但不是所有平台)的汇编代码,尽管它基本上是透明的,但也不是完全透明的; 要让Python使用它,需要使用补丁。)
当Python嵌入到其他应用程序中时,传统的GC也成为一个问题。在独立的Python中,可以用GC库提供的版本替换标准的malloc()和free(),嵌入Python的应用程序可能希望用 它自己 替代malloc()和free(),而可能不需要Python的。现在,CPython可以正确地实现malloc()和free()。
CPython退出时为什么不释放所有内存?
当Python退出时,从全局命名空间或Python模块引用的对象并不总是被释放。如果存在循环引用,则可能发生这种情况 C库分配的某些内存也是不可能释放的(例如像Purify这样的工具会抱怨这些内容)。但是,Python在退出时清理内存并尝试销毁每个对象。
如果要强制 Python 在释放时删除某些内容,请使用 atexit 模块运行一个函数,强制删除这些内容。
为什么有单独的元组和列表数据类型?
虽然列表和元组在许多方面是相似的,但它们的使用方式通常是完全不同的。可以认为元组类似于Pascal记录或C结构;它们是相关数据的小集合,可以是不同类型的数据,可以作为一个组进行操作。例如,笛卡尔坐标适当地表示为两个或三个数字的元组。
另一方面,列表更像其他语言中的数组。它们倾向于持有不同数量的对象,所有对象都具有相同的类型,并且逐个操作。例如, os.listdir('.') 返回表示当前目录中的文件的字符串列表。如果向目录中添加了一两个文件,对此输出进行操作的函数通常不会中断。
元组是不可变的,这意味着一旦创建了元组,就不能用新值替换它的任何元素。列表是可变的,这意味着您始终可以更改列表的元素。只有不变元素可以用作字典的key,因此只能将元组和非列表用作key。
列表如何在CPython中实现?
CPython的列表实际上是可变长度的数组,而不是lisp风格的链表。该实现使用对其他对象的引用的连续数组,并在列表头结构中保留指向该数组和数组长度的指针。
这使得索引列表 a[i] 的操作成本与列表的大小或索引的值无关。
当添加或插入项时,将调整引用数组的大小。并采用了一些巧妙的方法来提高重复添加项的性能; 当数组必须增长时,会分配一些额外的空间,以便在接下来的几次中不需要实际调整大小。
字典如何在CPython中实现?
CPython的字典实现为可调整大小的哈希表。与B-树相比,这在大多数情况下为查找(目前最常见的操作)提供了更好的性能,并且实现更简单。
字典的工作方式是使用 hash() 内置函数计算字典中存储的每个键的hash代码。hash代码根据键和每个进程的种子而变化很大;例如,'Python' 的hash值为-539294296,而'python'(一个按位不同的字符串)的hash值为1142331976。然后,hash代码用于计算内部数组中将存储该值的位置。假设您存储的键都具有不同的hash值,这意味着字典需要恒定的时间 -- O(1),用Big-O表示法 -- 来检索一个键。


原文:
https://docs.python.org/zh-cn/3.7/faq/design.html

(本文由Python大本营转载,转载请联系原作者)


点赞
收藏
回复
分享
举报
浏览267 编辑于2019-11-24 17:14未知归属地
全部评论
最多点赞
最新发布
最早发布
暂无评论
暂无评论
写回答
新增插入模板功能
一键使用模板,快速填写内容,轻松发帖~
知道了
  • 为了保障您的信息安全,请勿上传您的敏感个人信息(如您的密码等信息)和您的敏感资产信息(如关键源代码、签名私钥、调试安装包、业务日志等信息),且您需自行承担由此产生的信息泄露等安全风险。
  • 如您发布的内容为转载内容,请注明内容来源。

我要发帖子

了解社区公约,与您携手共创和谐专业的开发者社区。