为什么 Python、Ruby 等语言弃用了自增运算符?

网站建设4年前发布
36 0 0

2023030520365034e03af0894398e22147682b4ec9368a74a2ab912,许多人也许会注意到一个现象,那就是在一些现代编程语言(当然,并不是指“最近出现”的编程语言)中,自增和自减运算符被取消了。也就是说,在这些语言中不存在​​i++​​​或​​j--​​​这样的表达,而是只存在​​i += 1​​​或​​j -= 1​​这样的表达方式了。本回答将从设计哲学这个角度上探讨这一现象产生的背景与原因。,严格来说,说"i++正在消失"也许有失偏颇,因为主流编程语言中似乎只有Python、Rust和Swift不支持自增自减运算符。,当我第一次接触Python时,这也曾令我感到困惑。我曾经有兴趣地搜索了很多相关的回答和文章,但都没有得到满意的答案。如今数年过去了,我尝试重新思考这个问题,并给出我的答案。,请注意,本文仅“从设计哲学上”讨论这一问题,不会特别涉及语言本身的性质。例如在Python中,不提供自增自减运算符很大一部分原因是由于其整数类型为 Immutable 的,但这并不是“从设计哲学上”的讨论,因此本文不会包含相关内容。,维基百科指出,自增和自减运算符最早出现在B语言(即C的前身)中。B语言的发明者与C语言的发明者相同,也是K&R,其中Ken Thompson最早在B语言中引入了自增与自减运算符。因此也常常有人不太严谨地说“自增自减运算符最早起源于C”,事实情况虽然有些出入,但也差不了太多。,B语言的语法与C高度相似,最大的不同可能在于B是无类型的。不过,这里不太多介绍B语言,否则就偏离主题了。这里所要强调的只是自增自减运算符最早的起源。,关于为什么B语言中引入了自增自减运算符这个问题众说纷纭,Ken Thompson也从未公开表示过自己当初为何创建了这两个运算符。然而,有一个误解需要澄清,即这两个运算符的引入不可能是对应于汇编语言的​​INC​​和​​DEC​​指令。事实上,B语言的另一位创造者(当然,也是C语言的创造者)Dennis M. Ritchie曾在其回忆"The Development of the C Language"中指出:,文中的说法有些模糊,仅指出自增自减运算符不可能是产生于PDP-11的auto-increment和auto-decrement地址模式(因为B语言发明时这台机器甚至都不存在),然而并未指出其是否对应于汇编语言中的​​INC​​和​​DEC​​。为了验证这一说法,我找到了文中提到的PDP-7的指令集,的确不包含​​INC​​或​​DEC​​指令。为了严谨起见,我还查了一下PDP-7的汇编手册,也没有找到相关指令。这证明了自增自减运算符的发明不可能是由于其直接对应于汇编语言中的INC和DEC指令。,顺带一提,为了考证INC和DEC汇编指令的最初出现时间,我找到了1969年版的PDP-11 Handbook, 其中指出了INC和DEC是在PDP-11中被新引入的汇编指令(截图中没包含DEC,但手册后面有包含这条指令):,20230305203649a7df2e913b0b969049b649dbb8551cb922cd53382,PDP-11 Handbook, 1969, Page 34,PDP-11的正式发布时间是1970,而B语言的诞生时间是1969。除非Ken Thompson参与了PDP-11的早期开发工作,否则自增自减运算符的灵感不可能源于​​INC​​和​​DEC​​汇编指令。当然,正如Dennis Ritchie指出,早在PDP-7中就已经出现了auto-increment memory cells,很可能是它启发了Ken Thompson引入自增自减运算符。,另一个能够反驳“自增自减运算符直接对应于汇编指令”的事实是,B语言最初并不能直接编译成机器码,而是需要编译成一种被称作“线程码(threaded code)”的东西(原谅我找不到合适的翻译) 。既然最初都无法直接编译成机器码,那就更没有这种说法了。,所以说,自增自减运算符最初出现的原因可能非常简单——当年机器字节很珍贵,而++x能比x=x+1或x+=1少写一点代码,在那时候能少写一点代码总是好的——于是自增自减运算符出现了。,好吧,虽然上面已经严肃地论证了自增自减运算符的出现与PDP-11的ISA没关系,但K&R不过是C的创始人,他们懂什么C语言(雾)?K&R之后C语言的各种语法都被玩出花来了,恐怕他们也想不到C语言后续的发展。,自增自减运算符到底会不会被编译成​​INC​​和​​DEC​​,还得看现代的各种编译器。下面我在Ubuntu 22.04下将相关的C代码编译,然后反汇编,看看​​i++​​是否会被编译成​​INC​​,以验证“自增自减运算符能够提高程序运行效率”的逻辑是否成立。,下面是测试程序:,然后运行gcc,默认不开启优化:,然后运行objdump反汇编:,下面展示相关汇编代码(我所使用的是x86-64平台),已剔除无关代码:,可以看到,默认情况下并没有调用inc,仍然使用了 addl。,有人肯定要问了,是不是没有开优化的原因?好,那就开优化试试:,这次把addl改成了add,但inc还是没出现:,至于更高的优化级别,其汇编代码的可读性太差,就不贴出来了。但经过验证,即使是O3甚至Ofast优化级别的汇编代码中都看不到inc的身影。,也许在某些特殊的情况下​​i++​​会被编译成​​inc​​,但是如果要指望编译器将​​i++​​编译成​​inc​​这样的单指令以提高速度(其实inc甚至不是atomic的,因此也不要指望这能带来什么“原子性”),那确实是想当然了。事实上对于gcc来说,​​i++​​和​​i += 1​​没什么区别。,这会不会是gcc的问题?用clang会不会产生不一样的结果?答案是同样不会。,结果:,同理,对于clang,各种优化级别我也试过了,都见不到​​inc​​的影子。,上面的考证似乎有些太过分了,以至于稍微有些偏离了“从设计哲学上讨论”的初衷。上面讨论了这么多,只是为了证明自增自减运算符真的不能带来什么性能提升,在设计之初这两个运算符就没考虑过这方面的问题,而且出于各种原因,现代编译器也几乎不会把​​i++​​编译成​​inc​​(事实上,只有在非常陈旧的编译器中才会出现这样的情况,参见StackOverflow) 。而且,由于​​inc​​和​​dec​​并非原子指令,这也不能给程序带来任何“原子性”。,好吧,话题终于回归到“设计哲学”上了。现在已经排除了一切“为了性能/为了原子性/为了直接对应汇编语言……”而使用自增自减运算符的说法,这些更多是想当然的看法,而非事实。显然,那么答案只有从设计哲学上考虑了。,对于C/C++程序员,for循环语句是一个很得心应手的工具。C语言(甚至B语言)并非最早引入由分号分隔的for循环的语言,但却是真正将其推广开来的语言。,而自增自减操作符的引入,使得for循环变得极其强大,甚至许多C/C++程序员习惯到尽可能将代码压缩到一个以分号结尾的for循环语句(或while循环语句)中,使代码极为简洁。最初接触这些形式代码的程序员可能还不太习惯,但若看多了类似的写法,其实可以发现这些写法也非常简洁明白:,有些C/C++程序员认为这类传统for循环比起许多现代语言中采用迭代器的for更有优势,也更具表达能力。此外,由于C/C++中无法直接在数组中使用迭代器(不像Java后来可以加入迭代数组的语法糖),指针的递增和递减操作使用非常频繁,也相当重要,因此提供自增自减运算符无疑是很符合C/C++的设计哲学的。,事先声明,就像上面已经说过的,在C++中(甚至是任何采用传统for循环的语言中)可以认为自增自减运算符是利大于弊的,它使得代码变得更为简洁。而且在谨慎使用的前提下,也可能使得代码更加清晰。判断一个语法特性是否是个好设计,显然要看环境。这里只是指在许多精心设计的现代编程语言中,自增自减运算符似乎显得没那么重要了。,可以注意到,在许多编程语言中,具有副作用的操作符除了赋值操作符(包括但不限于=、+=、&=等),就只有自增和自减运算符了。显然,赋值操作符具有副作用是无奈之举,否则无法给变量赋值。,但在一众其他操作符,如+、-、&、||、<<中,唯独自增和自减运算符这两个具有副作用,会原地改变变量值,就显得十分奇怪。即使是三元运算符?:,其本身也不会产生副作用。,副作用的负面影响想必大家或多或少都在关于函数式编程的讨论中能听到一些。显然,纯函数是易于测试和组合的,对于相同的参数,纯函数每次运算都得到相同的结果。而自增和自减运算符从语法设计上就大大违背了函数式编程的不变性原则。,其实可以看到,排除不存在变量的纯函数式语言中不存在自增自减运算符,其实许多包含变量的混合范式(且偏向函数式)的编程语言也不存在自增自减运算符。除了文章一开头提到的Python、Rust和Swift,在其他偏函数式的混合范式语言如Scala中,也不原生存在自增自减运算符。,在一众运算符中,自增与自减运算符总因其具有副作用而显得独树一帜。对于重视函数式编程的语言来说,自增自减运算符是弊大于利的,也是很难被接受的。,可以想象,若有人尝试在混合范式语言中写函数式的代码,然后因为某些原因其中混进了一个​​i++​​,那恐怕是想找到BUG原因都很困难的——相比起​​i += 1​​,​​i++​​看起来确实太隐晦了,很难在杂乱的代码中一眼看出这是个赋值语句,认识到其有副作用的事实,这可能导致潜在的BUG。,近年来,似乎但凡是个新语言,都会优先采用迭代式循环而非C-style的传统for循环。即使像是Go这种复古语法的语言,也推荐优先使用range而非传统for循环。而Rust更是直接删除了传统for循环,只保留迭代式for循环。即使是那些老语言,也纷纷加入了迭代式循环,如Java、JavaScript、C++等,都陆续加入了相关语法。,简单对比一下各语言中的传统for循环和迭代式循环:,Java,JavaScript,Go,可以很明显地看到,使用迭代器减少了代码量,而且反而使得代码变得更加清晰。,当然,迭代器的作用不仅停留在表面的“减少代码”上。更重要的是迭代器减小了开发人员的心智负担。有过C/C++编程经验的人都知道,在传统for循环中更改i的值是非常危险的,一不留神就会造成严重的BUG甚至产生死循环。而迭代器的逻辑是不同的:每次循环从迭代器中取出值,而不是在某个值上递增。因此,即使不小心在使用迭代器的循环中错误更改了计数变量的值,也不会产生问题:,上面这段Python代码会是一个死循环吗?其实不会。因为​​for i in range(5)​​​的逻辑并非创建一个计数变量i,然后每次递增。其实现方式是先创建迭代器<range {0, 1, 2, 3, 4}>,然后依次从里面取值。i的取值在最初就已经固定了,因此在循环体中更改i的值并不会造成什么影响,到下一次循环时,i只是取迭代器中的下一个值,不管在上一次循环中有没有更改。当然,上面这样的代码是不建议在生产环境中编写的,容易造成误会。,可以看到,在现代编程语言中,迭代器替代了自增自减运算符绝大多数的使用场景,而且能够使得代码更加简洁与清晰。而对于那些只存在迭代式for循环的编程语言,如Python、Rust等,自然也就不那么必要加入自增自减运算符了。,熟悉C/C++的程序员肯定知道,赋值语句是有返回值的,也可以时常看到C/C++程序员写出下面这样的代码(Java中也可以实现这样的操作,但似乎Java程序员不太喜欢写这样的代码):,赋值语句的返回值即被赋值变量执行赋值语句之后的值。在上面的例子中,a最终等于5.,为什么赋值语句会有返回值,而不是返回一个null或者其他类似的东西?这很大程度上是为了满足连续赋值的需要:,上面的代码中,​​a = b = c = 5​​这句似乎太符合直觉,以至于人们常常忘记类似的连续赋值语句并非语法糖,而是赋值语句返回值的必然结果。赋值操作符是右结合的,因此上面这条语句先执行​​c = 5​​,然后返回5,再执行​​b = 5​​,以此类推,就实现了连续赋值。,在很多现代语言中,赋值语句都没有了返回值,或者其返回值只用于实现连续赋值,不允许作为表达式使用。例如在Go中,类似的语句就会报错,它甚至不支持连续赋值:,在Go中,赋值语句不能作为表达式,也自然没有赋值语句。同理,在Rust、Python等语言中,赋值语句也仅仅是“语句”而已,不能作为表达式使用,像是​​a = (b += c)​​这样的语句是不合法的。,不过,Python虽然不支持赋值语句作为表达式,但却是支持连续赋值的,像是​​a = b = c​​这样的语句是合法的。然而在这里,连续赋值就不是赋值语句返回值产生的自然结果了,在这里它确实是某种“语法糖”。,不过,有时候赋值表达式也不完全是一件坏事,它在特定情况下能够简化代码,使其更加清晰。例如在Python 3.8中,就加入了赋值表达式语法,使用“海象操作符(:=)”作为赋值表达式。例如:,……话题似乎有些扯远了,赋值语句返回值和自增自减运算符有什么关系?其实稍微想一想,就会发现它们之间有很强的关联性:自增自减运算虽然看起来不像赋值语句,但其本质上确实是赋值。既然赋值语句都没了返回值,不能作为表达式使用,那么自增自减运算符理论上也不该例外,也不该当作表达式使用。,可是若自增自减运算只能当作普通的赋值语句使用,那么就几乎只能​​i++​​、​​j--​​等语句单独成行了。而实际上,自增自减运算符更多的使用场景是作为表达式而非语句使用。这样一来,自增自减运算符的使用场景就变得非常有限了,而在本身已经存在迭代式循环的语言中,要使自增自减运算符单独成行使用的场景本就很罕见,那么加入自增自减运算符自然就显得没什么意义了。,当然,也存在例外。例如在Go中自增自减运算符也不是真正的“运算符”,而仅仅是赋值语句的语法糖,还真就只能单独成行使用。但Go就是任性地把它们加入到了语法中。例如下面的Go代码就会在编译时报错:,不过,Go选择保留自增自减运算符也并非毫无道理。毕竟Go中仍保留了C-Style的传统for循环,而​​for i := 0; i < len(arr); i++​​看起来还是要比​​for i := 0; i < len(arr); i += 1​​稍微简洁一些,因此就保留了它们。如果Go选择删除传统for循环,那大概率自增自减运算符就不复存在了。(虽然我个人认为其实现在自增自减运算符在Go中也没有太大存在价值),至此为止,自增自减运算符的大多数使用场景似乎已经被各种更现代的语法替代了。但似乎自增自减运算符还有一个很小的优势,就是可以简化单独成行的​​i += 1​​ 或​​j -= 1​​这样的赋值语句。比如说,需要在迭代数组的同时获得下标,那么​​i++​​是否能做到简化代码?,答案是不能,因为各大语言其实很早就考虑过这个问题了。比如在Python中,没经验的新手程序员可能会写出这样的代码,然后抱怨Python中为什么没有自增自减运算符:,或是写出这样的代码:,然而Python早就提供了enumerate函数用来解决这个问题,该函数会返回一个每次返回下标和元素的可迭代对象:,类似地,Go也可以在迭代时直接获取数组下标:,在Swift中也一样:,在Rust中:,在C++中并没有直接包含类似enumerate的语法,这个函数写起来其实也比较困难,但善用模板元编程也是可以实现的,感兴趣可以自己试试。,显然,在大多数包含迭代式循环语法的语言中,要在迭代对象的同时获取下标也是相当轻松的。即使那门语言中没有类似Python中enumerate的语法,手写一个类似的函数也没有那么困难。于是,自增自减运算符的使用场景被进一步压缩,现在即使是作为纯粹的语法糖当作单独成行的​​i += 1​​或​​j -= 1​​使用,好像也没太多使用场景了。,一般来说,自增和自减运算符都应视作与​​+= 1​​和​​-= 1​​同义 。然而,运算符重载使其产生了某些歧义。,若一门语言支持运算符重载,那么对于​​+=​​和​​++​​,有两种处理方法:,第一种,将++完全视作+= 1的语法糖。当重载​​+=​​运算符时,也自动重载​​++​​运算符。然而这会带来很严重的歧义,例如Python就重载了字符串上的​​+=​​运算符,如运行​​x = 'a'; x += 'b'​​ 后,x的值为'ab'。如果Python中存在​​++​​运算符,那么按照这一规则,​​x++​​就应被视为​​x += 1​​,现在这还没问题,会报类型不匹配错误。但是若Python像Java一样在拼接字符串时会自动进行类型转换,​​x += 1​​就变得合法了,同​​x += '1'​​,然后运行​​x++​​,x的值就会变成'ab1',这就极其匪夷所思了。,考虑一下在弱类型语言中这将产生什么样的灾难性后果,JS现在即使没有运算符重载都能写出​​let a = []; a++​​然后a的值为0这种黑魔法代码了。如果JS哪天加入了运算符重载,然后有人闲着没事去重载了内置类型上的​​+=​​运算符,那后果简直有点难以想象了。,第二种,将++视作与+=无关的操作符。这样做不会产生上面描述中那样匪夷所思的问题,但若选择这么做,当编程语言的使用者重载了​​+=​​运算符后,可能会自然而然地认为​​++​​运算符也被重载了,这可能带来更多歧义。,事实上,这里提到的运算符重载带来的歧义已经在很多语言中发生了。在同时支持自增自减运算符和操作符重载的语言中,由于类似原因产生的BUG已经并不少见了。一种解决方案是不允许重载​​++​​和​​--​​操作符,只允许它们在整数类型上使用。但既然这样了,为什么不考虑干脆去掉自增自减运算符呢?,可以注意到,在上面的讨论中,我有意忽视了许多语言本身的特性,例如在Python中,不存在自增自减运算符的另一大原因是因其整数是不可变类型,自增自减运算符容易带来歧义。,正如我在文章开头所说的,这属于Python的特性,不在这里的“设计哲学”讨论范畴内。不过,为了严谨起见,这里还是简单提一下。,此外,尽管在许多语言中,​​a = a + 1​​、​​a += 1​​和​​a++​​代表的意义都是相同的,但也存在不少语言区分这两者。在很多使用虚拟机的语言,如Python和Java中,​​a += 1​​作为原地操作与​​a = a + 1​​区别开来的。例如在Java中,​​a = a + 1​​使用字节码iadd实现,而​​a += 1​​和​​a++​​使用iinc实现。同理,在Python中,它们的字节码也有BINARY_ADD和INPLACE_ADD的区分。对于这些语言,​​a++​​到底表示​​a += 1​​还是​​a = a + 1​​,由于它们含义不同,或许又会产生一重歧义。,不得不说,Ken Thompson最初一拍脑袋想出来的​​++​​和​​--​​运算符产生的影响恐怕远远超出了本人的预料。许多人对自增和自减运算符起源和应用场景的理解也仅仅是停留在想当然的层面,诸如“提高运行效率”甚至“原子性操作”这样的误解也是满天飞。同时,C语言初学者(尤其是在国内)也常常被​​a = i++ + ++i + i++​​这种逆天未定义操作折腾到头疼欲裂。这两个小小的运算符究竟是带来了更多方便还是带来了更多麻烦,就留给读者自己去思考吧。,在许多现代编程语言中,自增和自减运算符的地位都被大大削弱了。有些语言严格限制了这两个运算符的使用,不允许其作为表达式使用,如Go;有些干脆取消了这两个运算符,认为​​+=​​和​​-=​​已经完全足够了,如Python和Rust。,在迭代器被越来越广泛使用的今天,​​++​​和​​--​​这两个在历史上曾占据重要地位的运算符似乎正在逐渐淡出人们的视野。我很难评价这是件好事还是坏事,毕竟我们也见到在诸如C/C++和Java这样的语言中,克制地使用自增和自减运算符有些时候也能使代码非常简洁明白。像Python和Rust一样完全取消这两个运算符是否过于极端了?这也很不好说。,总而言之,不论你是一个很擅长使用​​++​​和​​--​​的C/C++程序员,抑或是对这两个具有副作用的操作符天生厌恶的FP拥护者,都得承认随着程序设计语言的发展,自增和自减运算符正变得越来越不重要,但它们仍在特定场景下很有价值。

© 版权声明

相关文章