数据结构论坛

首页 » 分类 » 定义 » 计算机界TOP3难题相等是软件工程中
TUhjnbcbe - 2023/8/12 19:50:00

有一个笑话说,计算机科学界有两大难题:一是缓存失效问题,二是命名问题。但我认为还有第三个更难的问题:相等问题。你没看错,等号“=”看似简单,但等号的使用和误用,是软件工程中许多重大问题的根源。声明:本文已获作者CraigStuntz翻译授权。

作者

CraigStuntz

译者

弯月,责编

郭芮

头图

CSDN下载自视觉中国

出品

CSDN(ID:CSDNnews)

以下为译文:

相等的原理

我来展示一下在编程语言中相等有多么容易出错。但首先我要解释一下相等应有的模样,而这其实非常难解释!当我们讨论相等“应该如何”时,我们必须指出是特定语境下的相等,因为相等的成立有许多方式,许多方式在不同的语境下都是成立的。

“数学的精髓在于,同一个东西可以用不同的方式呈现给我们。”——BarryMazur,《Whenisonethingequaltosomeotherthing》

定律

我说过,在不同的语境下,相等有不同的含义,但尽管如此,有些东西是永远成立的。这就是相等的定律。

相等是一个二元操作,它是:

反射的,即对于任意值a都有a=a对称的,即a=b可以推出b=a,反之亦然传递的,即如果a=b且b=c,则a=c在编程的世界中,我们需要增加一条定律,因为有时候程序员会做一些奇怪的事情:

相等必须是:

一致的,即如果a=b且a或b的任何字段都没有发生变化,那么稍后再次检查时a=b应当依然成立。上面的定律看似简单,但流行的编程语言甚至连如此简单的定律都无法遵守。但更严重的关于相等的问题甚至都很难精确描述。

结构相等性

在编程语言对于相等的各种实现中,一个重要的区别就是结构相等和引用相等。

结构相等会检查两个引用是否为同一个值。F#默认采用了结构相等:

typeMyString={SomeField:string}

leta={SomeField=Somevalue}

letb={SomeField=Somevalue}

ifa=bthen//返回true,进入then代码块

但在C#中则不是这样,C#使用的是引用相等。引用相等要求两个被比较的对象是同一个对象。换句话说,它会比较两个变量是否指向同一个内存地址。指向两个不同内存地址的引用会被判为不相等,即使它们的值完全一样:

classMyString{

privatereadonlystringsomeField;

publicstringSomeField{get;}

publicMyString(stringsomeField)=this.someField=someField;

}

vara=newMyString(Somevalue);

varb=newMyString(Somevalue);

if(a==b){//返回false,不会进入代码块

其他语言会让你选择。例如,Scheme提供了equal?来检查结构相等,eq?来检查引用相等。Kotlin提供了==用于结构相等,===用于引用相等(不要与JavaScript的==和===操作符混淆,JavaScript的那个是完全不同的东西)。

程序中何时应当使用结构相等?如果变量的值不会改变更,那么几乎任何情况下都应该使用结构相等!我所知的绝大多数编程语言在诸如integers之类的类型上都使用结构比较。除了Java之外,int类型进行结构比较,Integer类型进行引用比较,这迷惑了一代又一代的程序员。Python的is也有类似的问题。

对于引用类型(如对象)也应当进行结构比较。考虑一个单元测试,你希望检查返回的对象是否等于你期待的值。在使用结构相等的语言中,这个操作非常简单:

[TestMethod]

let``Theresultofthecalculationistheexpectedvalue``()=

letexpected={SomeField=Somevalue;SomeOtherField=15;StillAnotherField=true;...}

letactual=calculate()

Assert.AreEqual(expected,actual)

但如果语言不支持结构相等,而开发者需要自行开发,就会遇到难题。

引用相等

但正如我刚才说过的那样,某些特定情况下不应该使用结构相等。一种情况就是语言支持变量内容改变的情况,而绝大多数编程语言都支持。当某个变量的值被改变后,说这个变量等于另一个变量显然是不合理的。当然,你可以说在进行比较的时刻,这两个变量(在结构上)是相等的,比如在单元测试的最后一行时是相等的,但一般情况下你无法假设这两个变量是同一个东西。这点理解起来有些困难,我来举例说明。

我们假设有一个对象,表示一个人。在采用了结构相等的F#中,我可以这样写:

typePerson={Name:string;Age:integer;Offspring:Personlist}

现在我有两个朋友Jane和Sue,她们都有一个叫John的儿子,年龄都是15岁。他们是不同的人,但姓名和年龄都一样。没问题!

letjane={Name=Jane;Age=47;Offspring=[{Name=John;Age=15;Offspring=[]}]}

letsue={Name=Sue;Age=35;Offspring=[{Name=John;Age=15;Offspring=[]}]}

也可以这样写:

letjohn={Name=John;Age=15;Offspring=[]};

letjane={Name=Jane;Age=47;Offspring=[john]}

letsue={Name=Sue;Age=35;Offspring=[john]}

这两段代码的功能完全一样。我没办法区别两个儿子,即使我知道他们是不同的人。但这没有问题!如果我需要区别他们,我可以把他们DNA的hash之类的属性加到Person类型中。但如果我只需要知道他们的名字和年龄,那么是否能区分两个对象并不重要,因为不管怎么区分,它们的值都是一样的。

假设Jane的儿子改名成Pat。F#不支持改变变量的值,所以我需要为John(还有Jane!)创建新的Person实例:

letnewJane={Name=Jane;Age=47;Offspring=[{Name=Pat;Age=15;Offspring=[]}]}

这个新的变量newJane似乎有点奇怪,但实际上并不会构成问题。上面的代码没有问题。现在用C#试一下,在C#中,变量默认情况下是可以修改的:

varjohn=newPerson(John,15,null);

varjane=newPerson(Jane,15,newListPerson{john});

varsue=newPerson(Sue,15,newListPerson{john});

这段代码显然是不正确的:如果Jane的儿子改名为Pat,我可以直接改变引用的值:

jane.Offspring.First().Name=Pat;

但我就会发现Sue的儿子也改名了!因此,即使两个儿子最初的名字是一样的,但他们并不相等!所以我应该写成:

varjane=newPerson(Jane,15,newListPerson{newPerson(John,15,null)});

varsue=newPerson(Sue,15,newListPerson{newPerson(John,15,null)});

这样Jane和Sue的孩子就是引用不相等。所以,在可以改变变量内容的语言中,默认采用引用相等是合理的。

另一种应该采用引用相等的情况是,事先知道引用相等的结果与结构相等相同。测试结构相等显然需要额外开销,如果真的需要测试结构相等,那么这个额外开销是正常的。但是,假设你创建了大量的对象,而且事先知道每个对象都是结构不相等的,那么花费额外开销来测试结构相等是没有必要的,因为仅仅测试引用相等就可以得出同样的结果。

相等性的表示

在实数中,0.……(无限循环小数)等于1。注意这里说的“实数”与编程语言中的Real类型不一样。在数学中,实数是无限的,而编程语言中的实数是有限的。因此,编程语言中没有0.……这样的写法,但没关系,你可以使用1,反正两者的值是一样的。

这本质上是数学家在表示实数系统时采用的一种选择。如果在系统中加入另外一种对象,比如无限小的数,那么0.……和1就不相等了。

“但是这并不等于说规范可以任意确定,因为不接受一种规范,必然会导致不得不发明奇怪的新对象,或者不得不放弃某些熟知的数学规则。”——TimothyGowers,《Mathmetics:AVeryShortIntroduction》

类似地,在实数系统中,1/2和2/4表示同样的值。

不要把这些“相等”与JavaScript或PHP中的“不严格”相等运算符==混淆。这些相等跟那些运算符不一样,这些相等依然遵循相等的定律。重要的是要认识到,对象的相等可以用不同的方式来表达。

在IEEE-浮点数系统中,-0=0。

内涵和外延

一个函数何时等于另一个函数?绝大多数编程语言会进行引用相等的比较,我觉得这没有问题。因为,对函数进行结构比较有什么意义呢?也许我们可以使用反射来检查函数的实现,看看它们实现是否一样?但怎样才叫“一样”?变量名是否必须完全一样?快速排序和归并排序是不是“一样”的函数?

因此我们说,只要函数对于同样的输入返回同样的输出(不管其内部实现如何),函数就是外延相等的,而如果内部定义也一样,则是内涵相等的。当然,这也取决于语境。可能在某个语境中,我需要常数时间的函数,在另一个语境中,速度无关紧要。重要的是,必须有语境才能定义相等,才能用它来比较两个函数。

我不知道是否有哪种语言在比较函数时尝试过采用引用相等之外的方法。但很容易想出,这会很有用!(例如,优化器尝试移除重复的代码等。)你只能自己实现,但我不得不说,没有相等比较,总要比错误的相等比较强。

相等和赋值

当程序员的第一天就学过,“等于”这个名字有两种不同的概念。一种是赋值,另一种是测试相等性。在JavaScript中需要这样写:

constaValue=someFunction();//赋值

if(aValue===3){//测试相等

这两者本质上是不同的。比较返回布尔值,而在面向表达式的语言(如Ruby)中,赋值返回被赋的值。

所以Ruby代码可以这样写:

a=b=c=3

实际上会把3赋给变量a,b和c。不要在引用类型上尝试,很可能得不到你想要的结果!

在非面向表达式的语言(如C#)中,赋值没有返回值。

在数学中,赋值和测试相等性都使用相等运算符:

ifaValue=3...

whereaValue=someFunction()

(而且在数学中,有时候=还用于其他关系,如合同(congruence)。与数学中的其他东西一样,这里也要区分语境;在阅读论文或书籍时必须注意语境。)

为什么数学不要求两种不同的操作,而编程语言要求?因为在数学中可以轻易判断出语境,而且也并非所有语言都要求不同的运算符。例如,F#中赋值和测试相等都采用=。尽管两者采用相同的符号,但赋值和测试相等是完全不同的操作。

letaValue=someFunction();//赋值

ifaValue=3then//测试相等

语法的选择部分出于历史原因:F#基于ML,而ML基于数学;而JavaScript的语法基于Java→C→Algo→FORTRAN。

用于编译FORTRAN代码的机器很难根据语法来区分两种情况,因此采用不同的运算符是合理的。于是C语言把这个“特性”带到了新的高度,所以你甚至可以写:

intaValue=someFunction();//赋值

if(aValue=3){//也是赋值!

给没有C语言经验的人解释一下:这段代码先用3覆盖aValue,然后由于表达式aValue=3的值为3,因此if的条件为真,因此会继续执行if块内的代码。通常这种写法都是错误的,因此许多C程序员会将if块的条件反过来写,来避免造成该错误:

intaValue=someFunction();//赋值

if(3==aValue){//测试相等

//[...]

if(3=aValue){//语法错误:无法将aValue赋值给3.

相等性的使用错误

通过上面的说明,希望大家都已经明白相等性并不简单,“正确”的实现取决于语境。尽管如此,编程语言经常会把最容易的地方搞错!很多时候,这是相等性与其他语言特性的组合造成的,如隐式类型转换。

常见错误:相等性不是反射的

回忆一下相等性的反射率,即任何值都等于它自身,a=a。

在.NET中,如果在值类型上调用Object.ReferenceEquals(),其参数会在执行方法之前分别进行打包,因此即使传递同一个实例,也会返回假:

(来自文档的例子)

intint1=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_EXPRESSIONISNULLANDSOME_OTHER_EXPRESSIONISNULL)

而更可能发生的情况是,开发者会忘记NULL的特殊规则从而导致bug。一些数据库服务器的SQL语言支持ISNOTDISTINCTFROM,它的功能才是=应该有的功能。(或者我应该说,它没有不做=应该做的事情?)否则,就必须使用上面例子中的SQL语句。最好的解决办法就是尽可能使用不允许NULL的列。

IEEE-浮点数也有同样的问题,即NaN!=NaN。一种解释是,NaN表示某个不确定的“非数字”结果,而不同计算得出的NaN并不一定是同一个不确定的非数字,所以这个比较本身就是不正确的。例如,square_root(-2)和infinity/infinity两者的结果都是NaN,但显然它们不一样!有时候SQL的NULL问题也可以类似地解释。这样造成的问题之一就是术语的含义过多:NaN和NULL表示的是“未知”,还是“不精确的值”,或者是“缺少值”?

对于此类正常的浮点运算中不会出现的问题,解决方法之一就是采用联合(union)类型。在F#中可以这样写:

typeMaybeFloat=

Floatoffloat

Imaginaryofreal:float*imaginary:float

Indeterminate

///...

然后就可以在计算中正确处理这些情况了。如果在计算中遇到预料之外的NaN,可以使用signalingNaN来抛出异常。

Rust提供了Eq和PartialEq两个trait。没有实现Eq,是==运算符不遵从反射率的一个信号,而Rust中的浮点类型就没有实现Eq。但即使不实现Eq,你依然可以在代码中使用==。实现Eq可以将对象作为hashmap的键使用,可能会导致其他地方的行为发生变化。

但是=和浮点数还有更严重的问题。

常见错误:相等过于精确

我想许多开发者都熟悉IEEE-浮点数的比较问题,因为绝大多数语言的“float”或“double”的实现都是IEEE-。10*(0.1)不等于1,因为“0.1”实际上等于0.,或0.1015。如果你对此问题感到陌生,你可以阅读这篇文章(

1
查看完整版本: 计算机界TOP3难题相等是软件工程中