有一個笑話說,計算機科學界有兩大難題:一是緩存失效問題,二是命名問題。但我認為還有第三個更難的問題:相等問題。你沒看錯,等號「=」看似簡單,但等號的使用和誤用,是軟體工程中許多重大問題的根源。聲明:本文已獲作者Craig Stuntz翻譯授權。
作者 | Craig Stuntz
譯者 | 彎月,責編 | 郭芮
頭圖 | CSDN 下載自視覺中國
出品 | CSDN(ID:CSDNnews)
以下為譯文:
相等的原理
我來展示一下在程式語言中相等有多麼容易出錯。但首先我要解釋一下相等應有的模樣,而這其實非常難解釋!當我們討論相等「應該如何」時,我們必須指出是特定語境下的相等,因為相等的成立有許多方式,許多方式在不同的語境下都是成立的。
「數學的精髓在於,同一個東西可以用不同的方式呈現給我們。」——Barry Mazur,《When is one thing equal to some other thing》
定律
我說過,在不同的語境下,相等有不同的含義,但儘管如此,有些東西是永遠成立的。這就是相等的定律。
相等是一個二元操作,它是:
反射的,即對於任意值 a 都有 a = a對稱的,即a = b可以推出b = a,反之亦然傳遞的,即如果a = b且b = c,則a = c在編程的世界中,我們需要增加一條定律,因為有時候程式設計師會做一些奇怪的事情:
相等必須是:
一致的,即如果a = b且a或b的任何欄位都沒有發生變化,那麼稍後再次檢查時a = b應當依然成立。上面的定律看似簡單,但流行的程式語言甚至連如此簡單的定律都無法遵守。但更嚴重的關於相等的問題甚至都很難精確描述。
結構相等性
在程式語言對於相等的各種實現中,一個重要的區別就是結構相等和引用相等。
結構相等會檢查兩個引用是否為同一個值。F#默認採用了結構相等:
type MyString = { SomeField : string }
let a = { SomeField = "Some value" }
let b = { SomeField = "Some value" }
if a = b then // 返回true, 進入 "then" 代碼塊
但在C#中則不是這樣,C#使用的是引用相等。引用相等要求兩個被比較的對象是同一個對象。換句話說,它會比較兩個變量是否指向同一個內存地址。指向兩個不同內存地址的引用會被判為不相等,即使它們的值完全一樣:
class MyString {
private readonly string someField;
public string SomeField { get; }
public MyString(string someField) => this.someField = someField;
}
var a = new MyString("Some value");
var b = new MyString("Some value");
if (a == b) { // 返回 false, 不會進入代碼塊
其他語言會讓你選擇。例如,Scheme提供了equal?來檢查結構相等,eq?來檢查引用相等。Kotlin提供了==用於結構相等,===用於引用相等(不要與JavaScript的==和===操作符混淆,JavaScript的那個是完全不同的東西)。
程序中何時應當使用結構相等?如果變量的值不會改變更,那麼幾乎任何情況下都應該使用結構相等!我所知的絕大多數程式語言在諸如integers之類的類型上都使用結構比較。除了Java之外,int類型進行結構比較,Integer類型進行引用比較,這迷惑了一代又一代的程式設計師。Python的is也有類似的問題。
對於引用類型(如對象)也應當進行結構比較。考慮一個單元測試,你希望檢查返回的對象是否等於你期待的值。在使用結構相等的語言中,這個操作非常簡單:
[<TestMethod>]
let ``The result of the calculation is the expected value``() =
let expected = { SomeField = "Some value"; SomeOtherField = 15; StillAnotherField = true; ... }
let actual = calculate()
Assert.AreEqual(expected, actual)
但如果語言不支持結構相等,而開發者需要自行開發,就會遇到難題。
引用相等
但正如我剛才說過的那樣,某些特定情況下不應該使用結構相等。一種情況就是語言支持變量內容改變的情況,而絕大多數程式語言都支持。當某個變量的值被改變後,說這個變量等於另一個變量顯然是不合理的。當然,你可以說在進行比較的時刻,這兩個變量(在結構上)是相等的,比如在單元測試的最後一行時是相等的,但一般情況下你無法假設這兩個變量是同一個東西。這點理解起來有些困難,我來舉例說明。
我們假設有一個對象,表示一個人。在採用了結構相等的F#中,我可以這樣寫:
type Person = { Name : string; Age : integer; Offspring : Person list }
現在我有兩個朋友Jane和Sue,她們都有一個叫John的兒子,年齡都是15歲。他們是不同的人,但姓名和年齡都一樣。沒問題!
let jane = { Name = "Jane"; Age = 47; Offspring = [ { Name = "John"; Age = 15; Offspring = [] } ] }
let sue = { Name = "Sue"; Age = 35; Offspring = [ { Name = "John"; Age = 15; Offspring = [] } ] }
也可以這樣寫:
let john = { Name = "John"; Age = 15; Offspring = [] };
let jane = { Name = "Jane"; Age = 47; Offspring = [ john ] }
let sue = { Name = "Sue"; Age = 35; Offspring = [ john ] }
這兩段代碼的功能完全一樣。我沒辦法區別兩個兒子,即使我知道他們是不同的人。但這沒有問題!如果我需要區別他們,我可以把他們DNA的hash之類的屬性加到Person類型中。但如果我只需要知道他們的名字和年齡,那麼是否能區分兩個對象並不重要,因為不管怎麼區分,它們的值都是一樣的。
假設Jane的兒子改名成Pat。F#不支持改變變量的值,所以我需要為John(還有Jane!)創建新的Person實例:
let newJane = { Name = "Jane"; Age = 47; Offspring = [ { Name = "Pat"; Age = 15; Offspring = [] } ] }
這個新的變量newJane似乎有點奇怪,但實際上並不會構成問題。上面的代碼沒有問題。現在用C#試一下,在C#中,變量默認情況下是可以修改的:
var john = new Person("John", 15, null);
var jane = new Person("Jane", 15, new List<Person> { john });
var sue = new Person("Sue", 15, new List<Person> { john });
這段代碼顯然是不正確的:如果Jane的兒子改名為Pat,我可以直接改變引用的值:
jane.Offspring.First().Name = "Pat";
但我就會發現Sue的兒子也改名了!因此,即使兩個兒子最初的名字是一樣的,但他們並不相等!所以我應該寫成:
var jane = new Person("Jane", 15, new List<Person> { new Person("John", 15, null) });
var sue = new Person("Sue", 15, new List<Person> { new Person("John", 15, null) });
這樣Jane和Sue的孩子就是引用不相等。所以,在可以改變變量內容的語言中,默認採用引用相等是合理的。
另一種應該採用引用相等的情況是,事先知道引用相等的結果與結構相等相同。測試結構相等顯然需要額外開銷,如果真的需要測試結構相等,那麼這個額外開銷是正常的。但是,假設你創建了大量的對象,而且事先知道每個對象都是結構不相等的,那麼花費額外開銷來測試結構相等是沒有必要的,因為僅僅測試引用相等就可以得出同樣的結果。
相等性的表示
在實數中,0.999……(無限循環小數)等於1。注意這裡說的「實數」與程式語言中的Real類型不一樣。在數學中,實數是無限的,而程式語言中的實數是有限的。因此,程式語言中沒有0.999……這樣的寫法,但沒關係,你可以使用1,反正兩者的值是一樣的。
這本質上是數學家在表示實數系統時採用的一種選擇。如果在系統中加入另外一種對象,比如無限小的數,那麼0.999……和1就不相等了。
「但是這並不等於說規範可以任意確定,因為不接受一種規範,必然會導致不得不發明奇怪的新對象,或者不得不放棄某些熟知的數學規則。」——Timothy Gowers,《Mathmetics: A Very Short Introduction》
類似地,在實數系統中,1/2和2/4表示同樣的值。
不要把這些「相等」與JavaScript或PHP中的「不嚴格」相等運算符==混淆。這些相等跟那些運算符不一樣,這些相等依然遵循相等的定律。重要的是要認識到,對象的相等可以用不同的方式來表達。
在IEEE-754浮點數系統中,-0 = 0。
內涵和外延
一個函數何時等於另一個函數?絕大多數程式語言會進行引用相等的比較,我覺得這沒有問題。因為,對函數進行結構比較有什麼意義呢?也許我們可以使用反射來檢查函數的實現,看看它們實現是否一樣?但怎樣才叫「一樣」?變量名是否必須完全一樣?快速排序和歸併排序是不是「一樣」的函數?
因此我們說,只要函數對於同樣的輸入返回同樣的輸出(不管其內部實現如何),函數就是外延相等的,而如果內部定義也一樣,則是內涵相等的。當然,這也取決於語境。可能在某個語境中,我需要常數時間的函數,在另一個語境中,速度無關緊要。重要的是,必須有語境才能定義相等,才能用它來比較兩個函數。
我不知道是否有哪種語言在比較函數時嘗試過採用引用相等之外的方法。但很容易想出,這會很有用!(例如,優化器嘗試移除重複的代碼等。)你只能自己實現,但我不得不說,沒有相等比較,總要比錯誤的相等比較強。
相等和賦值
當程式設計師的第一天就學過,「等於」這個名字有兩種不同的概念。一種是賦值,另一種是測試相等性。在JavaScript中需要這樣寫:
const aValue = someFunction(); // 賦值
if (aValue === 3) { // 測試相等
這兩者本質上是不同的。比較返回布爾值,而在面向表達式的語言(如Ruby)中,賦值返回被賦的值。
所以Ruby代碼可以這樣寫:
a = b = c = 3
實際上會把3賦給變量a,b和c。不要在引用類型上嘗試,很可能得不到你想要的結果!
在非面向表達式的語言(如C#)中,賦值沒有返回值。
在數學中,賦值和測試相等性都使用相等運算符:
if aValue = 3 ...
where aValue = someFunction()
(而且在數學中,有時候=還用於其他關係,如合同(congruence)。與數學中的其他東西一樣,這裡也要區分語境;在閱讀論文或書籍時必須注意語境。)
為什麼數學不要求兩種不同的操作,而程式語言要求?因為在數學中可以輕易判斷出語境,而且也並非所有語言都要求不同的運算符。例如,F#中賦值和測試相等都採用=。儘管兩者採用相同的符號,但賦值和測試相等是完全不同的操作。
let aValue = someFunction(); // 賦值
if aValue = 3 then // 測試相等
語法的選擇部分出於歷史原因:F#基於ML,而ML基於數學;而JavaScript的語法基於Java→C→Algo→FORTRAN。
用於編譯FORTRAN代碼的機器很難根據語法來區分兩種情況,因此採用不同的運算符是合理的。於是C語言把這個「特性」帶到了新的高度,所以你甚至可以寫:
int aValue = someFunction(); // 賦值
if (aValue = 3) { // 也是賦值!
給沒有C語言經驗的人解釋一下:這段代碼先用3覆蓋aValue,然後由於表達式aValue = 3的值為3,因此if的條件為真,因此會繼續執行if塊內的代碼。通常這種寫法都是錯誤的,因此許多C程式設計師會將if塊的條件反過來寫,來避免造成該錯誤:
int aValue = someFunction(); // 賦值
if (3 == aValue) { // 測試相等
// [...]
if (3 = aValue) { // 語法錯誤:無法將 aValue 賦值給 3.
相等性的使用錯誤
通過上面的說明,希望大家都已經明白相等性並不簡單,「正確」的實現取決於語境。儘管如此,程式語言經常會把最容易的地方搞錯!很多時候,這是相等性與其他語言特性的組合造成的,如隱式類型轉換。
常見錯誤:相等性不是反射的
回憶一下相等性的反射率,即任何值都等於它自身,a = a。
在.NET中,如果在值類型上調用Object.ReferenceEquals(),其參數會在執行方法之前分別進行打包,因此即使傳遞同一個實例,也會返回假:
(來自文檔的例子)
int int1 = 3;
Console.WriteLine(Object.ReferenceEquals(int1, int1)); // 輸出 False
這意味著在任何.NET語言中 a = a 都不一定為真,因此不滿足反射率。
在SQL中,NULL不等於自身,因此表達式NULL = NULL(或者更可能的情況是,SOME_EXPRESSION = SOME_OTHER_EXPRESSION時兩者都可能為null)會返回false。這會導致下面亂糟糟的語句:
WHERE (SOME_EXPRESSION = SOME_OTHER_EXPRESSION)
OR (SOME_EXPRESSION IS NULL AND SOME_OTHER_EXPRESSION IS NULL)
而更可能發生的情況是,開發者會忘記NULL的特殊規則從而導致bug。一些資料庫伺服器的SQL語言支持IS NOT DISTINCT FROM,它的功能才是=應該有的功能。(或者我應該說,它沒有不做=應該做的事情?)否則,就必須使用上面例子中的SQL語句。最好的解決辦法就是儘可能使用不允許NULL的列。
IEEE-754浮點數也有同樣的問題,即NaN != NaN。一種解釋是,NaN表示某個不確定的「非數字」結果,而不同計算得出的NaN並不一定是同一個不確定的非數字,所以這個比較本身就是不正確的。例如,square_root(-2)和infinity/infinity兩者的結果都是NaN,但顯然它們不一樣!有時候SQL的NULL問題也可以類似地解釋。這樣造成的問題之一就是術語的含義過多:NaN和NULL表示的是「未知」,還是「不精確的值」,或者是「缺少值」?
對於此類正常的浮點運算中不會出現的問題,解決方法之一就是採用聯合(union)類型。在F#中可以這樣寫:
type MaybeFloat =
| Float of float
| Imaginary of real: float * imaginary: float
| Indeterminate
| /// ...
然後就可以在計算中正確處理這些情況了。如果在計算中遇到預料之外的NaN,可以使用signaling NaN來拋出異常。
Rust提供了Eq和PartialEq兩個trait。沒有實現Eq,是==運算符不遵從反射率的一個信號,而Rust中的浮點類型就沒有實現Eq。但即使不實現Eq,你依然可以在代碼中使用==。實現Eq可以將對象作為hash map的鍵使用,可能會導致其他地方的行為發生變化。
但是=和浮點數還有更嚴重的問題。
常見錯誤:相等過於精確
我想許多開發者都熟悉IEEE-754浮點數的比較問題,因為絕大多數語言的「float」或「double」的實現都是IEEE-754。10 *(0.1) 不等於1,因為「0.1」實際上等於0.100000001490116119384765625,或0.1000000000000000055511151231257827021181583404541015625。如果你對此問題感到陌生,你可以閱讀這篇文章(https://randomascii.wordpress.com/2012/02/25/comparing-floating-point-numbers-2012-edition/),但這裡的重點是,在浮點數上使用==進行比較是完全不安全的!你必須決定哪些數字是重要的,然後據此進行比較。
(更糟糕的是,浮點數是許多其他類型的基礎,如某些語言中的TDateTime類型,所以即使一些相等比較本該合理的地方,也不能正常工作。)
比較浮點數的正確方法是看它們是否「相近」,而「相近」在不同語境下有不同的含義。這並不是簡單的==能夠完成的。如果你發現經常需要做這種事情,那麼也許你該考慮使用其他數據類型,如固定精度的小數。
既然如此,為什麼程式語言要在無法支持的類型上提供==比較呢?其實程式語言為每一種類型都提供了==,程式設計師需要依靠自己的知識來判斷哪些不能用。
SML的實現說明(http://sml-family.org/Basis/real.html)上這樣說:
判斷real是否為相等的類型,如果是,那麼相等本身的意義也是有問題的。IEEE指出,零的符號在比較中應當被忽略,而任意一個參數為NaN時,相等比較應當返回false。這些約束對於SML程式設計師來說非常麻煩。前者意味著 0 = ~0 為true,而r/0 = r/~0為false。後者意味著r = r可能出現返回false的異常情況,或者對於ref cell rr,可能存在 rr = rr 成立但是 !rr = !rr 不成立的情況。我們可以接受零的無符號比較,但是認為相等的反射率、結構相等,以及<>和not o =的等價性應當被保留。這些額外的複雜性讓我們作出決定,real不是具有相等性的類型。
通過禁止real擁有=運算,SML強迫開發者思考他們真正需要什麼樣的比較。我認為這個特性非常好!
F#提供了[<NoEquality>]屬性,來標記那些=不應該被使用的自定義類型。遺憾的是,他們並沒有將float做上標記!
常見錯誤:不相等的「相等」
PHP有兩個單獨的運算符:==和===。==的文檔將其稱為「相等」,並記載到「如果在類型轉換後$a等於$b則返回TRUE」。不幸的是,這意味著==運算符是不可靠的:
<?php
var_dump("608E-4234" == "272E-3063"); // true
?>
儘管這裡比較的是字符串,但PHP發現兩者都可以被轉換為數字,所以就進行了轉換。由於這兩個數字非常小(例如第一個數字是608 * 10^-4234),而我們之前說過,浮點數比較非常困難。將這兩者都轉換成浮點數float(0)將導致它們被四捨五入成同一個值,因此該比較返回真。
注意這與JavaScript的行為不同。JavaScript也有與PHP類似的(但並不是一樣的!)==和===運算符;但JavaScript會認為兩側都為字符串,然後返回比較結果false。
幸運的是,PHP提供了===(「全等」)運算符,在這種情況下能給出正確結果。我想說永遠不要使用==,但==會在對象上執行結構比較,有時候正是你需要的!因此我只能說,使用==時要格外小心,因為它不能在基礎類型上正確工作。
常見錯誤:相等不是對稱的
如果你要在Java中重載.equals(),那麼你必須負責確保相等的定律成立!
如果不加注意,那麼很容易就會導致不對稱的相等,即a.equals(b) != b.equals(a)。
即使不考慮null的情況(因為null會導致NullPointerException,而.equals()是允許這種情況發生的:https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#equals-java.lang.Object-),如果你繼承一個類並重載.equals(),也最好多加小心!
@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null)
return false;
if (!o.getClass().isAssignableFrom(getClass())) // 危險!這一步是錯的!
return false;
ThisClass thisClass = (ThisClass) o;
// 欄位比較
// ...
}
如果ThisClass和ASubtypeOfThisClass都用類似上面的代碼重載了.equals(),那麼a.equals(b)就可能不等於b.equals(a)!正確的比較應該是:
if (getClass() != o.getClass())
return false;
這不僅僅是我的個人看法,也是Object.equals()的契約的要求(https://docs.oracle.com/javase/8/docs/api/java/lang/Object.html#equals-java.lang.Object-)。
常見錯誤:相等沒有傳遞性
回憶一下相等比較的定律之一就是應當具有傳遞性,即如果a = b 且 b = c,那麼 a = c。不幸的是,與類型轉換(type coersion)放在一起後,許多語言都會在這裡出問題。
在JavaScript中,
'' == 0; // true
0 == '0'; // true
'' == '0'; // false!
因此在JavaScript中永遠不要使用==,應該使用===。
常見錯誤:相等性不一致
在Kotlin中,==會根據變量類型返回不同的值,即使對於同一個變量:
fun equalsFloat(a: Float, b: Float) {
println(a == b);
}
fun equalsAny(a: Any, b: Any) {
println(a == b);
}
fun main(args: Array<String>) {
val a = Float.NaN;
val b = Float.NaN;
equalsFloat(a, b);
equalsAny(a, b);
}
// prints false, true
這是一個非常不幸的語言特性組合,可能會導致違反直覺的行為。
常見錯誤:在應當使用結構相等的地方使用引用相等
考慮如下用C#編寫的MSTest單元測試:
[TestMethod]
public void Calculation_Is_Correct() {
var expected = new Result(SOME_EXPECTED_VALUE);
var actual = _service.DoCalculation(SOME_INPUT);
Assert.AreEqual(expected, actual);
}
這段代碼能正常工作嗎?我們不知道!Assert.AreEqual()最終會調用Object.Equals(),默認會進行引用比較。除非你重載了Result.Equals()進行結構比較,否則這個單元測試無法正常工作。Object.Equals()認為,如果類型是可改變的,那麼不應該重載。通常來說這是合理的,但在單元測試中卻未必。(這是因為.Equals()本應比較.GetHashCode(),而一個對象的hash code在對象的生命周期中應該不發生改變。).NET framework中對於引用類型的最接近「有保證的結構比較」的是IEquatable<T>,但Assert.AreEqual()並沒有使用,即使實現了也不會使用。
而NUnit的情況更糟(https://github.com/nunit/nunit/issues/1249)。
(相反,Java的Object.hashCode在對象的欄位發生變化時是允許變化的。https://docs.oracle.com/javase/7/docs/api/java/lang/Object.html#hashCode())
應該怎樣看待相等
沒想到關於=運算符我寫了這麼多還沒寫完!好吧,這已經遠遠超過了運算符本身。為什麼如此複雜?基本上有兩個原因:
非本質的複雜性:我們的程式語言在相等比較方面做得並不好。經常完全不能正常工作,甚至在不能正常工作時也不會明確表示這一點,例如會在本應進行引用比較的地方使用結構比較。本質的複雜性:相等性本身就是複雜的,如比較浮點數。而在諸如比較函數等邊緣情況下就更為複雜。另一種劃分方法就是「應該由程式語言的實現者負責解決的問題」(上面的「非本質的複雜性」)和「應該由程式語言的使用者負責解決的問題」。
程式語言應該怎麼做?
關於非本質的複雜性,現狀是幾乎每一種主流程式語言對於相等性的實現都有問題。這個「必須遵循幾個定律的簡單運算」正是程式語言為了保證正確性而依賴的東西!但在我看來,只有SML真正思考了怎樣在語義和運行時/標準庫方面同時保證符合定律的相等性,而SML完全不是主流語言。
首先,在禁止相等比較的地方,程式語言應該很容易創建不允許相等比較的類型,因為這易操作完全沒有必要複雜(如F#中的[<NoEquality>]),然後應該在標準庫中儘可能多地使用該特性,如浮點類型。
程式語言必須非常明確地指出結構相等和引用相等之間的差異。永遠都不應該存在行為不確定的情況。絕大多數程式語言會重載==,根據引用的類型(多數情況是根據值或引用的區別),用它來表示結構相等或引用相等,這樣做一定會讓開發者感到困惑。
Kotlin已經非常接近正確了,它的===表示引用相等,==表示結構相等,儘管出於某些原因,對於值類型它會將===看做==,而不是引發編譯錯誤。目標應該是減少開發者的困惑。它希望讓開發者明白,===表示「引用相等」,而不是等號越多越好。
我不知道還有哪些允許改變變量值的語言能夠用不困惑的方式處理結構相等的。但很容易想像理想狀態應該怎樣!準備兩個運算符,一個表示結構相等,一個表示引用相當,只在程式語言可以合理地支持的語境下才允許相應的運算符。例如,如果.NET的Object.ReferenceEquals和值類型不進行包裹,並且使用類似於IEquatable<T>的東西允許成功夠許願使用結構相等運算符,那麼開發者就很容易弄清楚哪個是哪個。
程式設計師應該怎麼做?
也許你讀了這篇文章後會覺得,「哇,相等好複雜!我還是不要編程了,回家種地算了。」但這篇文章如此之長的原因主要是太多的語言都做錯了。都作對的確需要些心思,但並不是太難。肯定比種地要簡單。
在已有的類型上進行相等比較時,先問問自己:
在這裡進行相等比較本身合理嗎?如果合理,那麼是應該進行結構比較,還是引用比較?對於相應的比較方法,我採用的程式語言提供了哪些支持?我採用的程式語言對於該比較方法的實現是正確的嗎?在設計自定義類型時也可以詢問類似的問題:
我的類型應該支持相等比較嗎?還是需要一個更複雜的比較,就像float那樣?我的類型應該是可改變的嗎?它會對相等性產生怎樣的影響?應該支持引用比較?還是結構比較?還是應該同時支持兩者?如果你的類型是可改變的,則應該考慮將其改成不可改變的。即使語言默認是可改變的,這一點也可以實現!這樣做除了能在相等性比較方面獲得許多好處之外,不可改變的架構還有許多其他的好處。採用了不可改變數據結構的C# Roslyn編譯器就是非常好的例子:
語法樹的第三個屬性是,它們是不可改變的,而且是線程安全的。這意味著,在獲得一棵樹之後,它就是當前代碼狀態的快照,而且永遠不會改變。這樣多個用戶可以在不同的線程中與同一個語法樹同時進行操作,而無需擔心死鎖或重複的問題。由於樹是不可改變的,也不能針對樹進行直接的改變,因此負責創建和修改語法樹的工廠方法實際上會創建樹的新快照。樹本身的效率很高,因為它會重用底層結點,所以創建新版本的速度很快,只需要使用少量內存。
——.NET Compiler Platform SDK文檔
原文:https://www.craigstuntz.com/posts/2020-03-09-equality-is-hard.html