為什么 Python、Ruby 等語(yǔ)言棄用了自增運(yùn)算符?
△點(diǎn)擊上方“Python貓”關(guān)注 ,回復(fù)“1”領(lǐng)取電子書(shū)

不知道你是否曾疑惑過(guò)這個(gè)問(wèn)題:為什么 Python 中沒(méi)有自增自減操作呢??jī)赡昵?,貓哥我在“Python為什么”系列中探討過(guò)這個(gè)話題(點(diǎn)擊閱讀)。今天分享的文章也是關(guān)于這個(gè)話題,而且非常詳細(xì),推薦一讀。
許多人也許會(huì)注意到一個(gè)現(xiàn)象,那就是在一些現(xiàn)代編程語(yǔ)言(當(dāng)然,并不是指“最近出現(xiàn)”的編程語(yǔ)言)中,自增和自減運(yùn)算符被取消了。也就是說(shuō),在這些語(yǔ)言中不存在i++或j--這樣的表達(dá),而是只存在i += 1或j -= 1這樣的表達(dá)方式了。本回答將從設(shè)計(jì)哲學(xué)這個(gè)角度上探討這一現(xiàn)象產(chǎn)生的背景與原因。
嚴(yán)格來(lái)說(shuō),說(shuō)"i++正在消失"也許有失偏頗,因?yàn)橹髁骶幊陶Z(yǔ)言中似乎只有Python、Rust和Swift不支持自增自減運(yùn)算符。
當(dāng)我第一次接觸Python時(shí),這也曾令我感到困惑。我曾經(jīng)有興趣地搜索了很多相關(guān)的回答和文章,但都沒(méi)有得到滿(mǎn)意的答案。如今數(shù)年過(guò)去了,我嘗試重新思考這個(gè)問(wèn)題,并給出我的答案。
請(qǐng)注意,本文僅“從設(shè)計(jì)哲學(xué)上”討論這一問(wèn)題,不會(huì)特別涉及語(yǔ)言本身的性質(zhì)。例如在Python中,不提供自增自減運(yùn)算符很大一部分原因是由于其整數(shù)類(lèi)型為 Immutable 的,但這并不是“從設(shè)計(jì)哲學(xué)上”的討論,因此本文不會(huì)包含相關(guān)內(nèi)容。
為什么會(huì)存在自增自減運(yùn)算符?
起源
維基百科指出,自增和自減運(yùn)算符最早出現(xiàn)在B語(yǔ)言(即C的前身)中。B語(yǔ)言的發(fā)明者與C語(yǔ)言的發(fā)明者相同,也是K&R,其中Ken Thompson最早在B語(yǔ)言中引入了自增與自減運(yùn)算符。因此也常常有人不太嚴(yán)謹(jǐn)?shù)卣f(shuō)“自增自減運(yùn)算符最早起源于C”,事實(shí)情況雖然有些出入,但也差不了太多。
B語(yǔ)言的語(yǔ)法與C高度相似,最大的不同可能在于B是無(wú)類(lèi)型的。不過(guò),這里不太多介紹B語(yǔ)言,否則就偏離主題了。這里所要強(qiáng)調(diào)的只是自增自減運(yùn)算符最早的起源。
關(guān)于為什么B語(yǔ)言中引入了自增自減運(yùn)算符這個(gè)問(wèn)題眾說(shuō)紛紜,Ken Thompson也從未公開(kāi)表示過(guò)自己當(dāng)初為何創(chuàng)建了這兩個(gè)運(yùn)算符。然而,有一個(gè)誤解需要澄清,即這兩個(gè)運(yùn)算符的引入不可能是對(duì)應(yīng)于匯編語(yǔ)言的INC和DEC指令。事實(shí)上,B語(yǔ)言的另一位創(chuàng)造者(當(dāng)然,也是C語(yǔ)言的創(chuàng)造者)Dennis M. Ritchie曾在其回憶"The Development of the C Language"中指出:
……Thompson went a step further by inventing the
++and--operators, which increment or decrement; their prefix or postfix position determines whether the alteration occurs before or after noting the value of the operand. They were not in the earliest versions of B, but appeared along the way. People often guess that they were created to use the auto-increment and auto-decrement address modes provided by the DEC PDP-11 on which C and Unix first became popular. This is historically impossible, since there was no PDP-11 when B was developed. The PDP-7, however, did have a few 'auto-increment' memory cells, with the property that an indirect memory reference through them incremented the cell. This feature probably suggested such operators to Thompson; the generalization to make them both prefix and postfix was his own. Indeed, the auto-increment cells were not used directly in implementation of the operators, and a stronger motivation for the innovation was probably his observation that the translation of++xwas smaller than that ofx=x+1.
文中的說(shuō)法有些模糊,僅指出自增自減運(yùn)算符不可能是產(chǎn)生于PDP-11的auto-increment和auto-decrement地址模式(因?yàn)锽語(yǔ)言發(fā)明時(shí)這臺(tái)機(jī)器甚至都不存在),然而并未指出其是否對(duì)應(yīng)于匯編語(yǔ)言中的INC和DEC。為了驗(yàn)證這一說(shuō)法,我找到了文中提到的PDP-7的指令集,的確不包含INC或DEC指令。為了嚴(yán)謹(jǐn)起見(jiàn),我還查了一下PDP-7的匯編手冊(cè),也沒(méi)有找到相關(guān)指令。這證明了自增自減運(yùn)算符的發(fā)明不可能是由于其直接對(duì)應(yīng)于匯編語(yǔ)言中的INC和DEC指令。
順帶一提,為了考證INC和DEC匯編指令的最初出現(xiàn)時(shí)間,我找到了1969年版的PDP-11 Handbook, 其中指出了INC和DEC是在PDP-11中被新引入的匯編指令(截圖中沒(méi)包含DEC,但手冊(cè)后面有包含這條指令):

PDP-11 Handbook, 1969, Page 34
PDP-11的正式發(fā)布時(shí)間是1970,而B(niǎo)語(yǔ)言的誕生時(shí)間是1969。除非Ken Thompson參與了PDP-11的早期開(kāi)發(fā)工作,否則自增自減運(yùn)算符的靈感不可能源于INC和DEC匯編指令。當(dāng)然,正如Dennis Ritchie指出,早在PDP-7中就已經(jīng)出現(xiàn)了auto-increment memory cells,很可能是它啟發(fā)了Ken Thompson引入自增自減運(yùn)算符。
另一個(gè)能夠反駁“自增自減運(yùn)算符直接對(duì)應(yīng)于匯編指令”的事實(shí)是,B語(yǔ)言最初并不能直接編譯成機(jī)器碼,而是需要編譯成一種被稱(chēng)作“線程碼(threaded code)”的東西(原諒我找不到合適的翻譯) 。既然最初都無(wú)法直接編譯成機(jī)器碼,那就更沒(méi)有這種說(shuō)法了。
所以說(shuō),自增自減運(yùn)算符最初出現(xiàn)的原因可能非常簡(jiǎn)單——當(dāng)年機(jī)器字節(jié)很珍貴,而++x能比x=x+1或x+=1少寫(xiě)一點(diǎn)代碼,在那時(shí)候能少寫(xiě)一點(diǎn)代碼總是好的——于是自增自減運(yùn)算符出現(xiàn)了。
提高程序運(yùn)行效率?原子性?
好吧,雖然上面已經(jīng)嚴(yán)肅地論證了自增自減運(yùn)算符的出現(xiàn)與PDP-11的ISA沒(méi)關(guān)系,但K&R不過(guò)是C的創(chuàng)始人,他們懂什么C語(yǔ)言(霧)?K&R之后C語(yǔ)言的各種語(yǔ)法都被玩出花來(lái)了,恐怕他們也想不到C語(yǔ)言后續(xù)的發(fā)展。自增自減運(yùn)算符到底會(huì)不會(huì)被編譯成INC和DEC,還得看現(xiàn)代的各種編譯器。下面我在Ubuntu 22.04下將相關(guān)的C代碼編譯,然后反匯編,看看i++是否會(huì)被編譯成INC,以驗(yàn)證“自增自減運(yùn)算符能夠提高程序運(yùn)行效率”的邏輯是否成立。
下面是測(cè)試程序:
// incr_test.c
#include <stdio.h>
int main(void)
{
for (int i = 0; i < 5; i++)
{
printf("%d", i);
}
return 0;
}
然后運(yùn)行g(shù)cc,默認(rèn)不開(kāi)啟優(yōu)化:
gcc -o incr_test incr_test.c
然后運(yùn)行objdump反匯編:
objdump -d incr_test.c
下面展示相關(guān)匯編代碼(我所使用的是x86-64平臺(tái)),已剔除無(wú)關(guān)代碼:
0000000000001149 <main>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 48 89 e5 mov %rsp,%rbp
1151: 48 83 ec 10 sub $0x10,%rsp
1155: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
115c: eb 1d jmp 117b <main+0x32>
115e: 8b 45 fc mov -0x4(%rbp),%eax
1161: 89 c6 mov %eax,%esi
1163: 48 8d 05 9a 0e 00 00 lea 0xe9a(%rip),%rax # 2004 <_IO_stdin_used+0x4>
116a: 48 89 c7 mov %rax,%rdi
116d: b8 00 00 00 00 mov $0x0,%eax
1172: e8 d9 fe ff ff call 1050 <printf@plt>
1177: 83 45 fc 01 addl $0x1,-0x4(%rbp)
117b: 83 7d fc 04 cmpl $0x4,-0x4(%rbp)
117f: 7e dd jle 115e <main+0x15>
1181: b8 00 00 00 00 mov $0x0,%eax
1186: c9 leave
1187: c3 ret
可以看到,默認(rèn)情況下并沒(méi)有調(diào)用inc,仍然使用了 addl。
有人肯定要問(wèn)了,是不是沒(méi)有開(kāi)優(yōu)化的原因?好,那就開(kāi)優(yōu)化試試:
gcc -o incr_test incr_test.c -O1
objdump -d incr_test.c
這次把a(bǔ)ddl改成了add,但inc還是沒(méi)出現(xiàn):
0000000000001149 <main>:
1149: f3 0f 1e fa endbr64
114d: 55 push %rbp
114e: 53 push %rbx
114f: 48 83 ec 08 sub $0x8,%rsp
1153: bb 00 00 00 00 mov $0x0,%ebx
1158: 48 8d 2d a5 0e 00 00 lea 0xea5(%rip),%rbp # 2004 <_IO_stdin_used+0x4>
115f: 89 da mov %ebx,%edx
1161: 48 89 ee mov %rbp,%rsi
1164: bf 01 00 00 00 mov $0x1,%edi
1169: b8 00 00 00 00 mov $0x0,%eax
116e: e8 dd fe ff ff call 1050 <__printf_chk@plt>
1173: 83 c3 01 add $0x1,%ebx
1176: 83 fb 05 cmp $0x5,%ebx
1179: 75 e4 jne 115f <main+0x16>
117b: b8 00 00 00 00 mov $0x0,%eax
1180: 48 83 c4 08 add $0x8,%rsp
1184: 5b pop %rbx
1185: 5d pop %rbp
1186: c3 ret
至于更高的優(yōu)化級(jí)別,其匯編代碼的可讀性太差,就不貼出來(lái)了。但經(jīng)過(guò)驗(yàn)證,即使是O3甚至Ofast優(yōu)化級(jí)別的匯編代碼中都看不到inc的身影。也許在某些特殊的情況下i++會(huì)被編譯成inc,但是如果要指望編譯器將i++編譯成inc這樣的單指令以提高速度(其實(shí)inc甚至不是atomic的,因此也不要指望這能帶來(lái)什么“原子性”),那確實(shí)是想當(dāng)然了。事實(shí)上對(duì)于gcc來(lái)說(shuō),i++和i += 1沒(méi)什么區(qū)別。
這會(huì)不會(huì)是gcc的問(wèn)題?用clang會(huì)不會(huì)產(chǎn)生不一樣的結(jié)果?答案是同樣不會(huì)。
clang -o incr_test incr_test.c
objdump -d incr_test
結(jié)果:
0000000000001140 <main>:
1140: 55 push %rbp
1141: 48 89 e5 mov %rsp,%rbp
1144: 48 83 ec 10 sub $0x10,%rsp
1148: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
114f: c7 45 f8 00 00 00 00 movl $0x0,-0x8(%rbp)
1156: 83 7d f8 05 cmpl $0x5,-0x8(%rbp)
115a: 0f 8d 1f 00 00 00 jge 117f <main+0x3f>
1160: 8b 75 f8 mov -0x8(%rbp),%esi
1163: 48 8d 3d 9a 0e 00 00 lea 0xe9a(%rip),%rdi # 2004 <_IO_stdin_used+0x4>
116a: b0 00 mov $0x0,%al
116c: e8 bf fe ff ff call 1030 <printf@plt>
1171: 8b 45 f8 mov -0x8(%rbp),%eax
1174: 83 c0 01 add $0x1,%eax
1177: 89 45 f8 mov %eax,-0x8(%rbp)
117a: e9 d7 ff ff ff jmp 1156 <main+0x16>
117f: 31 c0 xor %eax,%eax
1181: 48 83 c4 10 add $0x10,%rsp
1185: 5d pop %rbp
1186: c3 ret
同理,對(duì)于clang,各種優(yōu)化級(jí)別我也試過(guò)了,都見(jiàn)不到inc的影子。
簡(jiǎn)潔性
上面的考證似乎有些太過(guò)分了,以至于稍微有些偏離了“從設(shè)計(jì)哲學(xué)上討論”的初衷。上面討論了這么多,只是為了證明自增自減運(yùn)算符真的不能帶來(lái)什么性能提升,在設(shè)計(jì)之初這兩個(gè)運(yùn)算符就沒(méi)考慮過(guò)這方面的問(wèn)題,而且出于各種原因,現(xiàn)代編譯器也幾乎不會(huì)把i++編譯成inc(事實(shí)上,只有在非常陳舊的編譯器中才會(huì)出現(xiàn)這樣的情況,參見(jiàn)StackOverflow) 。而且,由于inc和dec并非原子指令,這也不能給程序帶來(lái)任何“原子性”。
好吧,話題終于回歸到“設(shè)計(jì)哲學(xué)”上了?,F(xiàn)在已經(jīng)排除了一切“為了性能/為了原子性/為了直接對(duì)應(yīng)匯編語(yǔ)言……”而使用自增自減運(yùn)算符的說(shuō)法,這些更多是想當(dāng)然的看法,而非事實(shí)。顯然,那么答案只有從設(shè)計(jì)哲學(xué)上考慮了。
對(duì)于C/C++程序員,for循環(huán)語(yǔ)句是一個(gè)很得心應(yīng)手的工具。C語(yǔ)言(甚至B語(yǔ)言)并非最早引入由分號(hào)分隔的for循環(huán)的語(yǔ)言,但卻是真正將其推廣開(kāi)來(lái)的語(yǔ)言。而自增自減操作符的引入,使得for循環(huán)變得極其強(qiáng)大,甚至許多C/C++程序員習(xí)慣到盡可能將代碼壓縮到一個(gè)以分號(hào)結(jié)尾的for循環(huán)語(yǔ)句(或while循環(huán)語(yǔ)句)中,使代碼極為簡(jiǎn)潔。最初接觸這些形式代碼的程序員可能還不太習(xí)慣,但若看多了類(lèi)似的寫(xiě)法,其實(shí)可以發(fā)現(xiàn)這些寫(xiě)法也非常簡(jiǎn)潔明白:
for(vector<int>::iterator iter = vec.begin(); iter != vec.end(); add(*(iter++)));
for(size_t i = 0; arr[i] == 0; i++);
while(v->data[i++] > 5);
while(--i) { ... }
有些C/C++程序員認(rèn)為這類(lèi)傳統(tǒng)for循環(huán)比起許多現(xiàn)代語(yǔ)言中采用迭代器的for更有優(yōu)勢(shì),也更具表達(dá)能力。此外,由于C/C++中無(wú)法直接在數(shù)組中使用迭代器(不像Java后來(lái)可以加入迭代數(shù)組的語(yǔ)法糖),指針的遞增和遞減操作使用非常頻繁,也相當(dāng)重要,因此提供自增自減運(yùn)算符無(wú)疑是很符合C/C++的設(shè)計(jì)哲學(xué)的。
為什么一些現(xiàn)代編程語(yǔ)言取消了自增自減運(yùn)算符?
事先聲明,就像上面已經(jīng)說(shuō)過(guò)的,在C++中(甚至是任何采用傳統(tǒng)for循環(huán)的語(yǔ)言中)可以認(rèn)為自增自減運(yùn)算符是利大于弊的,它使得代碼變得更為簡(jiǎn)潔。而且在謹(jǐn)慎使用的前提下,也可能使得代碼更加清晰。判斷一個(gè)語(yǔ)法特性是否是個(gè)好設(shè)計(jì),顯然要看環(huán)境。這里只是指在許多精心設(shè)計(jì)的現(xiàn)代編程語(yǔ)言中,自增自減運(yùn)算符似乎顯得沒(méi)那么重要了。
副作用
可以注意到,在許多編程語(yǔ)言中,具有副作用的操作符除了賦值操作符(包括但不限于=、+=、&=等),就只有自增和自減運(yùn)算符了。顯然,賦值操作符具有副作用是無(wú)奈之舉,否則無(wú)法給變量賦值。但在一眾其他操作符,如+、-、&、||、<<中,唯獨(dú)自增和自減運(yùn)算符這兩個(gè)具有副作用,會(huì)原地改變變量值,就顯得十分奇怪。即使是三元運(yùn)算符?:,其本身也不會(huì)產(chǎn)生副作用。
副作用的負(fù)面影響想必大家或多或少都在關(guān)于函數(shù)式編程的討論中能聽(tīng)到一些。顯然,純函數(shù)是易于測(cè)試和組合的,對(duì)于相同的參數(shù),純函數(shù)每次運(yùn)算都得到相同的結(jié)果。而自增和自減運(yùn)算符從語(yǔ)法設(shè)計(jì)上就大大違背了函數(shù)式編程的不變性原則。其實(shí)可以看到,排除不存在變量的純函數(shù)式語(yǔ)言中不存在自增自減運(yùn)算符,其實(shí)許多包含變量的混合范式(且偏向函數(shù)式)的編程語(yǔ)言也不存在自增自減運(yùn)算符。除了文章一開(kāi)頭提到的Python、Rust和Swift,在其他偏函數(shù)式的混合范式語(yǔ)言如Scala中,也不原生存在自增自減運(yùn)算符。
在一眾運(yùn)算符中,自增與自減運(yùn)算符總因其具有副作用而顯得獨(dú)樹(shù)一幟。對(duì)于重視函數(shù)式編程的語(yǔ)言來(lái)說(shuō),自增自減運(yùn)算符是弊大于利的,也是很難被接受的??梢韵胂?,若有人嘗試在混合范式語(yǔ)言中寫(xiě)函數(shù)式的代碼,然后因?yàn)槟承┰蚱渲谢爝M(jìn)了一個(gè)i++,那恐怕是想找到BUG原因都很困難的——相比起i += 1,i++看起來(lái)確實(shí)太隱晦了,很難在雜亂的代碼中一眼看出這是個(gè)賦值語(yǔ)句,認(rèn)識(shí)到其有副作用的事實(shí),這可能導(dǎo)致潛在的BUG。
迭代器替代了大多數(shù)自增自減運(yùn)算符的使用場(chǎng)景
近年來(lái),似乎但凡是個(gè)新語(yǔ)言,都會(huì)優(yōu)先采用迭代式循環(huán)而非C-style的傳統(tǒng)for循環(huán)。即使像是Go這種復(fù)古語(yǔ)法的語(yǔ)言,也推薦優(yōu)先使用range而非傳統(tǒng)for循環(huán)。而Rust更是直接刪除了傳統(tǒng)for循環(huán),只保留迭代式for循環(huán)。即使是那些老語(yǔ)言,也紛紛加入了迭代式循環(huán),如Java、JavaScript、C++等,都陸續(xù)加入了相關(guān)語(yǔ)法。
簡(jiǎn)單對(duì)比一下各語(yǔ)言中的傳統(tǒng)for循環(huán)和迭代式循環(huán):
Java
int[] arr = { 1, 2, 3, 4, 5 };
// 傳統(tǒng)計(jì)數(shù)循環(huán)
for (int i = 0; i < arr.length; i++) {
System.out.println(arr[i]);
}
// 迭代
for (int num: arr) {
System.out.println(num);
}
JavaScript
const arr = [1, 2, 3, 4, 5]
// 傳統(tǒng)計(jì)數(shù)循環(huán)
for (let i = 0; i < arr.length; i++) {
console.log(arr[i])
}
// 迭代
for (const num of arr) {
console.log(num)
}
Go
arr := [5]int{1, 2, 3, 4, 5}
// 傳統(tǒng)計(jì)數(shù)循環(huán)
for i := 0; i < len(arr); i++ {
fmt.Println(arr[i])
}
// 迭代
for _, num := range arr {
fmt.Println(num)
}
可以很明顯地看到,使用迭代器減少了代碼量,而且反而使得代碼變得更加清晰。
當(dāng)然,迭代器的作用不僅停留在表面的“減少代碼”上。更重要的是迭代器減小了開(kāi)發(fā)人員的心智負(fù)擔(dān)。有過(guò)C/C++編程經(jīng)驗(yàn)的人都知道,在傳統(tǒng)for循環(huán)中更改i的值是非常危險(xiǎn)的,一不留神就會(huì)造成嚴(yán)重的BUG甚至產(chǎn)生死循環(huán)。而迭代器的邏輯是不同的:每次循環(huán)從迭代器中取出值,而不是在某個(gè)值上遞增。因此,即使不小心在使用迭代器的循環(huán)中錯(cuò)誤更改了計(jì)數(shù)變量的值,也不會(huì)產(chǎn)生問(wèn)題:
for i in range(5):
i -= 1
上面這段Python代碼會(huì)是一個(gè)死循環(huán)嗎?其實(shí)不會(huì)。因?yàn)?code style="font-size: 14px;word-wrap: break-word;border-radius: 4px;font-family: Operator Mono, Consolas, Monaco, Menlo, monospace;word-break: break-all;color: #9b6e23;background-color: #fff5e3;padding: 3px;margin: 3px;">for i in range(5)的邏輯并非創(chuàng)建一個(gè)計(jì)數(shù)變量i,然后每次遞增。其實(shí)現(xiàn)方式是先創(chuàng)建迭代器<range {0, 1, 2, 3, 4}>,然后依次從里面取值。i的取值在最初就已經(jīng)固定了,因此在循環(huán)體中更改i的值并不會(huì)造成什么影響,到下一次循環(huán)時(shí),i只是取迭代器中的下一個(gè)值,不管在上一次循環(huán)中有沒(méi)有更改。當(dāng)然,上面這樣的代碼是不建議在生產(chǎn)環(huán)境中編寫(xiě)的,容易造成誤會(huì)。
可以看到,在現(xiàn)代編程語(yǔ)言中,迭代器替代了自增自減運(yùn)算符絕大多數(shù)的使用場(chǎng)景,而且能夠使得代碼更加簡(jiǎn)潔與清晰。而對(duì)于那些只存在迭代式for循環(huán)的編程語(yǔ)言,如Python、Rust等,自然也就不那么必要加入自增自減運(yùn)算符了。
賦值語(yǔ)句返回值的消失
熟悉C/C++的程序員肯定知道,賦值語(yǔ)句是有返回值的,也可以時(shí)??吹紺/C++程序員寫(xiě)出下面這樣的代碼(Java中也可以實(shí)現(xiàn)這樣的操作,但似乎Java程序員不太喜歡寫(xiě)這樣的代碼):
int a = 1, b = 2, c = 3;
a = (b += 3);
賦值語(yǔ)句的返回值即被賦值變量執(zhí)行賦值語(yǔ)句之后的值。在上面的例子中,a最終等于5.
為什么賦值語(yǔ)句會(huì)有返回值,而不是返回一個(gè)null或者其他類(lèi)似的東西?這很大程度上是為了滿(mǎn)足連續(xù)賦值的需要:
int a = 1, b = 2, c = 3;
a = b = c = 5;
上面的代碼中,a = b = c = 5這句似乎太符合直覺(jué),以至于人們常常忘記類(lèi)似的連續(xù)賦值語(yǔ)句并非語(yǔ)法糖,而是賦值語(yǔ)句返回值的必然結(jié)果。賦值操作符是右結(jié)合的,因此上面這條語(yǔ)句先執(zhí)行c = 5,然后返回5,再執(zhí)行b = 5,以此類(lèi)推,就實(shí)現(xiàn)了連續(xù)賦值。
在很多現(xiàn)代語(yǔ)言中,賦值語(yǔ)句都沒(méi)有了返回值,或者其返回值只用于實(shí)現(xiàn)連續(xù)賦值,不允許作為表達(dá)式使用。例如在Go中,類(lèi)似的語(yǔ)句就會(huì)報(bào)錯(cuò),它甚至不支持連續(xù)賦值:
var a = 1
var b = 2
var c = 3
a = b = c = 5 // 報(bào)錯(cuò)
在Go中,賦值語(yǔ)句不能作為表達(dá)式,也自然沒(méi)有賦值語(yǔ)句。同理,在Rust、Python等語(yǔ)言中,賦值語(yǔ)句也僅僅是“語(yǔ)句”而已,不能作為表達(dá)式使用,像是a = (b += c)這樣的語(yǔ)句是不合法的。
不過(guò),Python雖然不支持賦值語(yǔ)句作為表達(dá)式,但卻是支持連續(xù)賦值的,像是a = b = c這樣的語(yǔ)句是合法的。然而在這里,連續(xù)賦值就不是賦值語(yǔ)句返回值產(chǎn)生的自然結(jié)果了,在這里它確實(shí)是某種“語(yǔ)法糖”。
不過(guò),有時(shí)候賦值表達(dá)式也不完全是一件壞事,它在特定情況下能夠簡(jiǎn)化代碼,使其更加清晰。例如在Python 3.8中,就加入了賦值表達(dá)式語(yǔ)法,使用“海象操作符(:=)”作為賦值表達(dá)式。例如:
found = {name: batches for name in order
if (batches := get_batches(stock.get(name, 0), 8))}
……話題似乎有些扯遠(yuǎn)了,賦值語(yǔ)句返回值和自增自減運(yùn)算符有什么關(guān)系?其實(shí)稍微想一想,就會(huì)發(fā)現(xiàn)它們之間有很強(qiáng)的關(guān)聯(lián)性:自增自減運(yùn)算雖然看起來(lái)不像賦值語(yǔ)句,但其本質(zhì)上確實(shí)是賦值。既然賦值語(yǔ)句都沒(méi)了返回值,不能作為表達(dá)式使用,那么自增自減運(yùn)算符理論上也不該例外,也不該當(dāng)作表達(dá)式使用。
可是若自增自減運(yùn)算只能當(dāng)作普通的賦值語(yǔ)句使用,那么就幾乎只能i++、j--等語(yǔ)句單獨(dú)成行了。而實(shí)際上,自增自減運(yùn)算符更多的使用場(chǎng)景是作為表達(dá)式而非語(yǔ)句使用。這樣一來(lái),自增自減運(yùn)算符的使用場(chǎng)景就變得非常有限了,而在本身已經(jīng)存在迭代式循環(huán)的語(yǔ)言中,要使自增自減運(yùn)算符單獨(dú)成行使用的場(chǎng)景本就很罕見(jiàn),那么加入自增自減運(yùn)算符自然就顯得沒(méi)什么意義了。
當(dāng)然,也存在例外。例如在Go中自增自減運(yùn)算符也不是真正的“運(yùn)算符”,而僅僅是賦值語(yǔ)句的語(yǔ)法糖,還真就只能單獨(dú)成行使用。但Go就是任性地把它們加入到了語(yǔ)法中。例如下面的Go代碼就會(huì)在編譯時(shí)報(bào)錯(cuò):
i := 0
j := i++
不過(guò),Go選擇保留自增自減運(yùn)算符也并非毫無(wú)道理。畢竟Go中仍保留了C-Style的傳統(tǒng)for循環(huán),而for i := 0; i < len(arr); i++看起來(lái)還是要比for i := 0; i < len(arr); i += 1稍微簡(jiǎn)潔一些,因此就保留了它們。如果Go選擇刪除傳統(tǒng)for循環(huán),那大概率自增自減運(yùn)算符就不復(fù)存在了。(雖然我個(gè)人認(rèn)為其實(shí)現(xiàn)在自增自減運(yùn)算符在Go中也沒(méi)有太大存在價(jià)值)
想要獲取下標(biāo)怎么辦?
至此為止,自增自減運(yùn)算符的大多數(shù)使用場(chǎng)景似乎已經(jīng)被各種更現(xiàn)代的語(yǔ)法替代了。但似乎自增自減運(yùn)算符還有一個(gè)很小的優(yōu)勢(shì),就是可以簡(jiǎn)化單獨(dú)成行的i += 1 或j -= 1這樣的賦值語(yǔ)句。比如說(shuō),需要在迭代數(shù)組的同時(shí)獲得下標(biāo),那么i++是否能做到簡(jiǎn)化代碼?
答案是不能,因?yàn)楦鞔笳Z(yǔ)言其實(shí)很早就考慮過(guò)這個(gè)問(wèn)題了。比如在Python中,沒(méi)經(jīng)驗(yàn)的新手程序員可能會(huì)寫(xiě)出這樣的代碼,然后抱怨Python中為什么沒(méi)有自增自減運(yùn)算符:
lst = ['a', 'b', 'c', 'd', 'e']
i = 0
for c in lst:
print(i, c)
i += 1
或是寫(xiě)出這樣的代碼:
lst = ['a', 'b', 'c', 'd', 'e']
for i in range(len(lst)):
c = lst[i]
print(i, c)
然而Python早就提供了enumerate函數(shù)用來(lái)解決這個(gè)問(wèn)題,該函數(shù)會(huì)返回一個(gè)每次返回下標(biāo)和元素的可迭代對(duì)象:
lst = ['a', 'b', 'c', 'd', 'e']
for i, c in enumerate(lst):
print(i, c)
類(lèi)似地,Go也可以在迭代時(shí)直接獲取數(shù)組下標(biāo):
arr := [5]int{1, 2, 3, 4, 5}
for i, num := range arr {
fmt.Println(i, num)
}
在Swift中也一樣:
let arr: [String] = ["a", "b", "c", "d"]
for (i, c) in arr.enumerated() {
print(i, c)
}
在Rust中:
let arr = [1, 2, 3, 4, 5];
for (i, &num) in arr.iter().enumerate() {
println!("arr[{}] = {}", i, num);
}
在C++中并沒(méi)有直接包含類(lèi)似enumerate的語(yǔ)法,這個(gè)函數(shù)寫(xiě)起來(lái)其實(shí)也比較困難,但善用模板元編程也是可以實(shí)現(xiàn)的,感興趣可以自己試試。
顯然,在大多數(shù)包含迭代式循環(huán)語(yǔ)法的語(yǔ)言中,要在迭代對(duì)象的同時(shí)獲取下標(biāo)也是相當(dāng)輕松的。即使那門(mén)語(yǔ)言中沒(méi)有類(lèi)似Python中enumerate的語(yǔ)法,手寫(xiě)一個(gè)類(lèi)似的函數(shù)也沒(méi)有那么困難。于是,自增自減運(yùn)算符的使用場(chǎng)景被進(jìn)一步壓縮,現(xiàn)在即使是作為純粹的語(yǔ)法糖當(dāng)作單獨(dú)成行的i += 1或j -= 1使用,好像也沒(méi)太多使用場(chǎng)景了。(關(guān)聯(lián)閱讀:《Python 為什么不支持自增語(yǔ)法》)
運(yùn)算符重載帶來(lái)歧義
一般來(lái)說(shuō),自增和自減運(yùn)算符都應(yīng)視作與+= 1和-= 1同義 。然而,運(yùn)算符重載使其產(chǎn)生了某些歧義。
若一門(mén)語(yǔ)言支持運(yùn)算符重載,那么對(duì)于+=和++,有兩種處理方法:
第一種,將++完全視作+= 1的語(yǔ)法糖。當(dāng)重載+=運(yùn)算符時(shí),也自動(dòng)重載++運(yùn)算符。然而這會(huì)帶來(lái)很?chē)?yán)重的歧義,例如Python就重載了字符串上的+=運(yùn)算符,如運(yùn)行x = 'a'; x += 'b' 后,x的值為'ab'。如果Python中存在++運(yùn)算符,那么按照這一規(guī)則,x++就應(yīng)被視為x += 1,現(xiàn)在這還沒(méi)問(wèn)題,會(huì)報(bào)類(lèi)型不匹配錯(cuò)誤。但是若Python像Java一樣在拼接字符串時(shí)會(huì)自動(dòng)進(jìn)行類(lèi)型轉(zhuǎn)換,x += 1就變得合法了,同x += '1',然后運(yùn)行x++,x的值就會(huì)變成'ab1',這就極其匪夷所思了。
考慮一下在弱類(lèi)型語(yǔ)言中這將產(chǎn)生什么樣的災(zāi)難性后果,JS現(xiàn)在即使沒(méi)有運(yùn)算符重載都能寫(xiě)出let a = []; a++然后a的值為0這種黑魔法代碼了。如果JS哪天加入了運(yùn)算符重載,然后有人閑著沒(méi)事去重載了內(nèi)置類(lèi)型上的+=運(yùn)算符,那后果簡(jiǎn)直有點(diǎn)難以想象了。
第二種,將++視作與+=無(wú)關(guān)的操作符。這樣做不會(huì)產(chǎn)生上面描述中那樣匪夷所思的問(wèn)題,但若選擇這么做,當(dāng)編程語(yǔ)言的使用者重載了+=運(yùn)算符后,可能會(huì)自然而然地認(rèn)為++運(yùn)算符也被重載了,這可能帶來(lái)更多歧義。
事實(shí)上,這里提到的運(yùn)算符重載帶來(lái)的歧義已經(jīng)在很多語(yǔ)言中發(fā)生了。在同時(shí)支持自增自減運(yùn)算符和操作符重載的語(yǔ)言中,由于類(lèi)似原因產(chǎn)生的BUG已經(jīng)并不少見(jiàn)了。一種解決方案是不允許重載++和--操作符,只允許它們?cè)谡麛?shù)類(lèi)型上使用。但既然這樣了,為什么不考慮干脆去掉自增自減運(yùn)算符呢?
一些其他的討論
可以注意到,在上面的討論中,我有意忽視了許多語(yǔ)言本身的特性,例如在Python中,不存在自增自減運(yùn)算符的另一大原因是因其整數(shù)是不可變類(lèi)型,自增自減運(yùn)算符容易帶來(lái)歧義。(關(guān)聯(lián)閱讀:《Python 為什么不支持自增語(yǔ)法》)
正如我在文章開(kāi)頭所說(shuō)的,這屬于Python的特性,不在這里的“設(shè)計(jì)哲學(xué)”討論范疇內(nèi)。不過(guò),為了嚴(yán)謹(jǐn)起見(jiàn),這里還是簡(jiǎn)單提一下。
此外,盡管在許多語(yǔ)言中,a = a + 1、a += 1和a++代表的意義都是相同的,但也存在不少語(yǔ)言區(qū)分這兩者。在很多使用虛擬機(jī)的語(yǔ)言,如Python和Java中,a += 1作為原地操作與a = a + 1區(qū)別開(kāi)來(lái)的。例如在Java中,a = a + 1使用字節(jié)碼iadd實(shí)現(xiàn),而a += 1和a++使用iinc實(shí)現(xiàn)。同理,在Python中,它們的字節(jié)碼也有BINARY_ADD和INPLACE_ADD的區(qū)分。對(duì)于這些語(yǔ)言,a++到底表示a += 1還是a = a + 1,由于它們含義不同,或許又會(huì)產(chǎn)生一重歧義。
總結(jié)
不得不說(shuō),Ken Thompson最初一拍腦袋想出來(lái)的++和--運(yùn)算符產(chǎn)生的影響恐怕遠(yuǎn)遠(yuǎn)超出了本人的預(yù)料。許多人對(duì)自增和自減運(yùn)算符起源和應(yīng)用場(chǎng)景的理解也僅僅是停留在想當(dāng)然的層面,諸如“提高運(yùn)行效率”甚至“原子性操作”這樣的誤解也是滿(mǎn)天飛。同時(shí),C語(yǔ)言初學(xué)者(尤其是在國(guó)內(nèi))也常常被a = i++ + ++i + i++這種逆天未定義操作折騰到頭疼欲裂。這兩個(gè)小小的運(yùn)算符究竟是帶來(lái)了更多方便還是帶來(lái)了更多麻煩,就留給讀者自己去思考吧。
在許多現(xiàn)代編程語(yǔ)言中,自增和自減運(yùn)算符的地位都被大大削弱了。有些語(yǔ)言嚴(yán)格限制了這兩個(gè)運(yùn)算符的使用,不允許其作為表達(dá)式使用,如Go;有些干脆取消了這兩個(gè)運(yùn)算符,認(rèn)為+=和-=已經(jīng)完全足夠了,如Python和Rust。
在迭代器被越來(lái)越廣泛使用的今天,++和--這兩個(gè)在歷史上曾占據(jù)重要地位的運(yùn)算符似乎正在逐漸淡出人們的視野。我很難評(píng)價(jià)這是件好事還是壞事,畢竟我們也見(jiàn)到在諸如C/C++和Java這樣的語(yǔ)言中,克制地使用自增和自減運(yùn)算符有些時(shí)候也能使代碼非常簡(jiǎn)潔明白。像Python和Rust一樣完全取消這兩個(gè)運(yùn)算符是否過(guò)于極端了?這也很不好說(shuō)。
總而言之,不論你是一個(gè)很擅長(zhǎng)使用++和--的C/C++程序員,抑或是對(duì)這兩個(gè)具有副作用的操作符天生厭惡的FP擁護(hù)者,都得承認(rèn)隨著程序設(shè)計(jì)語(yǔ)言的發(fā)展,自增和自減運(yùn)算符正變得越來(lái)越不重要,但它們?nèi)栽谔囟▓?chǎng)景下很有價(jià)值。

還不過(guò)癮?試試它們
▲Python 為什么不支持 i++ 自增語(yǔ)法,不提供 ++ 操作符?
▲Python 中如何快速實(shí)現(xiàn)一個(gè)線程池?
▲我國(guó)為什么做不出 JetBrains 那樣的產(chǎn)品?
▲為什么 Python 3 把 print 改為函數(shù)?
