你的编程语言不合理吗?(2015)

2020-10-11 16:05:30

很明显,这个站点的目标之一是说服人们认真对待F#作为一种通用开发语言。

但随着函数式习惯用法变得越来越主流,C#增加了lambdas和LINQ等函数功能,似乎C#越来越“赶上”F#。

“C#已经具备了F#的大部分功能,我为什么还要费心去切换呢?”*。

“没有必要改变。我们所要做的就是等待几年,C#将获得许多提供最大好处的F#特性。“。

“F#略好于C#,但还没有好到真正值得朝它努力的程度。”

“F#看起来真的很不错,尽管有点吓人。但我看不出在C#上使用它有什么实际目的。“。

毫无疑问,既然Java也有lambdas,那么在JVM生态系统中,关于Scala和Clojure与Java的对比也出现了同样的评论。

因此,在这篇文章中,我将离开F#,将重点放在C#(以及通过代理和其他主流语言),并试图演示,即使拥有世界上所有的函数特性,用C#编程也永远不会和用F#编程相同。

在开始之前,我想明确表示我并不讨厌C#。碰巧我非常喜欢C#;它是我最喜欢的主流语言之一,它已经发展到非常强大,同时具有一致性和向后兼容性,这是一件很难做到的事情。

但是C#并不完美。像大多数主流面向对象语言一样,它包含了一些设计决策,这是任何LINQ或lambda的优点都无法弥补的。

在这篇文章中,我将向您展示这些设计决策导致的一些问题,并提出一些改进语言以避免这些问题的方法。

)我现在要穿上我的防火套装。我想我可能需要它!)。

更新:似乎很多人都严重误读了这篇文章。所以让我说清楚:

我并不是说静态类型的语言比动态语言“更好”。

我并不是说能够对代码进行推理是一门语言最重要的方面。

不能对代码进行推理会带来许多开发人员可能没有意识到的代价。

因此,在选择编程语言时,“合理”应该是考虑的因素之一,而不是仅仅因为缺乏意识而被忽视。

如果您想要能够对您的代码进行推理,那么如果您的语言支持我提到的功能,就会容易得多。

OO(Object-Identity,Behavior-Based)的基本范式与“合理性”不兼容,因此很难改造现有的OO语言来增加这一特性。

如果你和函数式程序员在一起,你会经常听到“关于的原因”这个短语,比如“我们想要对我们的程序进行推理”。

那是什么意思?为什么要用“原因”这个词而不是“理解”呢?

“推理”的用法可以追溯到数学和逻辑,但我将使用一个简单而实用的定义:

“关于代码的推理”意味着您可以只使用眼前的信息得出结论,而不必钻研代码库的其他部分。

换句话说,您只需查看代码就可以预测它的行为。您可能需要了解其他组件的接口,但您不应该查看它们的内部以了解它们的功能。

由于作为开发人员,我们将大部分时间花在查看代码上,这是编程的一个非常重要的方面!

当然,有大量关于如何做到这一点的建议:命名准则、格式规则、设计模式等等。

但是,您的编程语言本身是否可以帮助您的代码变得更合理、更可预测呢?我想答案是肯定的,但我会让你自己判断。

下面,我将提供一系列代码片段。在每段代码之后,我会问您认为代码是做什么的。我故意不展示我自己的评论,这样你就可以考虑一下,做你自己的推理了。在你考虑过之后,向下滚动阅读我的意见。

答案是-1。你得到答案了吗?不是吗?如果你想不出来,再往下滚动一遍。

函数doSomething(Foo){x=false}var x=2;doSomething(X);var y=x-1;

是的,太可怕了!DoSomething直接访问x,而不是通过参数,然后将其转换为所有内容的布尔值!然后,从x中减去1将其从False转换为0,因此y为-1。

你不是很讨厌这样吗?很抱歉在语言方面误导您,但我只是想演示一下,当语言以不可预测的方式运行时,它是多么烦人。

JavaScript是一种非常有用和重要的语言。但没有人会声称合理性是它的优势之一,事实上,大多数动态类型语言都有一些怪癖,使得它们很难用这种方式进行推理。

多亏了静态类型和合理的作用域规则,这种事情在C#中永远不会发生(除非您真的很努力!)在C#中,如果您没有正确匹配类型,您会得到编译时错误,而不是运行时错误。

换句话说,C#比JavaScript更容易预测。静态打字得1分!

与JavaScript相比,C#看起来不错。但是我们还没做完呢…。

更新:这是一个无可否认的愚蠢的例子。回想起来,我本可以选一个更好的。是的,我知道任何理智的人都不会这样做。这一点仍然成立:JavaScript语言不会阻止您使用隐式类型转换做一些愚蠢的事情。

在下一个示例中,我们将创建同一Customer类的两个实例,其中包含完全相同的数据。

//创建两个客户var客户1=新客户(99,#34;J Smith&34;);var客户2=新客户(99,#34;J Smith&34;);//TRUE或FALSE?奶油1。等于(保证金2);

谁知道呢?这取决于Customer类是如何实现的。此代码不可预测。

您至少必须查看类是否实现了IEquatable,并且可能还必须查看类的内部结构,以了解到底发生了什么。

由于忘记重写equals方法而导致的bug有多少次?

由于错误实现GetHashCode(例如,当您比较的字段发生更改时忘记更改它),您是否经常遇到错误?

为什么不在缺省情况下使对象相等,并在特殊情况下使引用相等测试?

在下一个示例中,我有两个包含完全相同数据的对象,但它们是不同类的实例。

//创建客户和订单var cust=new Customer(99,";J Smith&34;);var order=new order(99,";J Smith&34;);//TRUE还是FALSE?客户。等于(顺序);

谁在乎呢!几乎可以肯定这是一个错误!为什么你一开始就要比较两个不同的班级呢?

当然,可以比较它们的名称或ID,但不是对象本身。这应该是编译器错误。

如果不是,有何不可呢?您可能只是错误地使用了错误的变量名,但是现在您的代码中有一个细微的错误。为什么你的语言允许你这样做?

更新:很多人指出,在比较通过继承关联的类时,您需要这样做。当然,这是真的。但是这个功能的费用是多少呢?您获得了比较子类的能力,但失去了检测意外错误的能力。

在实践中,哪个更重要?这是由你决定的,我只是想清楚地表明,与现状相关的是成本,而不仅仅是利益。

在这段代码中,我们将创建一个Customer实例。就这样。没有比这更基本的了。

这取决于Address属性是否为空。如果不再次查看Customer类的内部结构,您就无法判断这一点。

是的,我们知道构造函数应该在构造时初始化所有字段是最佳实践,但是为什么语言不强制执行呢?

如果地址是必需的,则使其在构造函数中是必需的。如果地址并不总是必需的,则明确表示Address属性是可选的,并且可能会丢失。

对象必须始终初始化为有效状态。不这样做是编译时错误。

//创建客户var cust=new Customer(99,";J Smith&34;);//将其添加到集合var procsedCustomers=new HashSet<;Customer>;();processedCustomers。Add(Cust);//Process it ProcessCustomer(Cust);//集合中是否包含客户?对还是错?已处理客户。包含(客户);

首先,客户的散列代码是否依赖于可变字段,如id。

如果两者都为真,那么散列将被更改,并且客户将看起来不再存在于集合中(即使它仍然在那里的某个地方!)。

这很可能会导致微妙的性能和内存问题(例如,如果集合是高速缓存)。

一种方法是说GetHashCode中使用的任何字段或属性都必须是不可变的,同时允许其他属性是可变的。但这真的是不切实际的。

现在,如果Customer类是不可变的,并且ProcessCustomer想要进行更改,则它必须返回Customer的新版本,代码将如下所示:

//创建客户var cust=new ImmutableCustomer(99,";J Smith&34;);//将其添加到集合var procsedCustomers=new HashSet<;ImmutableCustomer>;();processedCustomers。Add(Cust);//处理后返回变更var ChangedCustomer=ProcessCustomer(Cust);//TRUE还是FALSE?已处理客户。包含(客户);

很明显,仅通过查看这段代码,ProcessCustomer就已经更改了一些内容。如果ProcessCustomer没有更改任何内容,它根本不需要返回对象。

回到这个问题上来,很明显,在这个实现中,无论ProcessCustomer做什么,都保证客户的原始版本仍然在集合中。

当然,这并不能解决新的或旧的(或两者都)应该在集合中的问题,但与使用可变客户的实现不同,这个问题现在正摆在您的面前,不会意外地被忽视。

对象必须始终初始化为有效状态。不这样做是编译时错误。

Haskell程序员不会“更换”灯泡,他们会“更换”灯泡。而且你还必须同时更换整个房子。“。

在最后一个示例中,我们将尝试从CustomerRepository获取客户。

//创建存储库var repo=new CustomerRepository();//通过id查找客户var Customer=repo。GetById(42);//预期输出是多少?控制台。WriteLine(客户。ID);

问题是:在执行Customer=repo.GetById(42)之后,customer.Id的值是什么?

如果我查看GetById的方法签名,它告诉我它总是返回一个客户。但真的是这样吗?

如果客户丢失了怎么办?Repo.GetById是否返回NULL?它会抛出异常吗?您不能仅通过查看我们已有的代码来判断。

特别是,返回NULL是一件可怕的事情。这是一个伪装成客户的叛徒,可以在编译器没有投诉的情况下被分配给客户变量,但当您实际要求它做某事时,它会在您面前发出邪恶的咯咯声。不幸的是,我不能通过查看此代码来判断是否返回NULL。

异常稍微好一点,因为至少它们是类型化的,并且包含有关上下文的信息。但是从方法签名看不出可能抛出哪些异常,您可以确定的唯一方法是查看内部源代码(如果幸运并且文档是最新的,还可以查看文档)。

但是现在想象一下,您的语言不允许NULL,也不允许异常。你还能做些什么呢?

答案是,您将被迫返回一个可能包含Customer或错误的特殊类,如下所示:

//创建仓库var repo=new CustomerRepository();//根据id查找客户//返回CustomerOrError结果var customerOrError=repo。GetById(42);

然后,处理此“customerOrError”结果的代码必须测试它是哪种类型的结果,并分别处理每种情况,如下所示:

这正是大多数函数式语言所采用的方法。如果语言提供便利来简化这项技术(如SUM类型),这确实会有所帮助,但即使没有这种便利,如果您想要清楚地了解代码正在做什么,这种方法仍然是唯一的方法。(您可以在此处阅读有关此技术的更多信息。)

这是最后两个要添加到我们清单上的项目,至少目前是这样。

对象必须始终初始化为有效状态。不这样做是编译时错误。

我还可以继续,用一些片段来说明滥用全局变量、副作用、强制转换等等。不过,我想我就到此为止吧--你现在可能已经想明白了!

我希望很明显,将这些添加到编程语言中将有助于使其更加合理。

其次,这些变化中的许多都与面向对象编程模型本身的本质背道而驰。

例如,在OO模型中,对象标识是最重要的,因此引用相等当然是默认设置。

此外,从面向对象的角度来看,如何比较两个对象完全取决于对象本身-OO完全是关于多态行为的,编译器需要置身事外!类似地,如何构造和初始化对象也完全取决于对象本身。没有规则来说明什么应该允许,什么不应该允许。

最后,在不实现第4点中的初始化约束的情况下,向静态类型的OO语言添加不可为空的引用类型是非常困难的。正如Eric Lippert自己所说的那样,“非空性是您希望从第一天就被烘焙到类型系统中的东西,而不是您想要在12年后改进的东西”。

相比之下,大多数函数式编程语言都将这些“高可预测性”特性作为语言的核心部分。

例如,在F#中,该列表中除一项外的所有项都内置于该语言中:

不允许值更改其类型。(比方说,这甚至包括从整型到浮点型的隐式强制转换)。

值必须初始化为有效状态。不这样做是编译时错误。

第7项不是由编译器强制执行的,但是区别联合(SUM类型)通常用于返回错误,而不是使用异常,以便函数签名准确地指示可能的错误是什么。

的确,在使用F#时,仍然有很多注意事项。您可以拥有可变的值,可以创建和抛出异常,并且可能确实需要处理来自非F#代码的空值。

但这些都被认为是代码气味,是不寻常的,而不是一般的默认情况。

其他语言,如Haskell,甚至比F#更纯粹(因此更合理),但即使是Haskell程序也不会完美。

事实上,没有一种语言可以完美地推理并仍然实用。但尽管如此,有些语言肯定比其他语言更合理。

我认为这是许多人对函数式代码如此热情的原因之一(尽管它充满了奇怪的符号,但仍称之为“简单”!)。就是这样:不变性,无副作用,以及所有其他功能原则,一起行动来加强这种合理性和可预测性,这反过来又有助于减轻您的认知负担,这样您只需要关注您面前的代码。

因此,现在应该很清楚,这个提议的改进列表与lambdas或聪明的函数库等语言增强没有任何关系。

换句话说,当我专注于理性时,我不在乎我的语言会让我做什么,我更关心的是我的语言不会让我做什么。我想要一种能阻止我错误地做蠢事的语言。

也就是说,如果我必须在不允许空值的语言A和类型更高但仍然允许对象为空值的语言B之间进行选择,我会毫不犹豫地选择语言A。

问:这些例子都是很做作的!如果您仔细编写代码并遵循良好的实践,您可以在没有这些功能的情况下编写安全的代码!

可以,停那儿吧。我不是说你不能,但这篇文章不是关于写安全代码,而是关于代码的推理。这是有区别的。

这并不是说如果你小心的话你能做些什么。这是关于如果你不小心的话会发生什么!也就是说,您的编程语言(不是您的编码指南、测试、IDE或开发实践)是否支持您对代码进行推理?

问:你是说一门语言应该具备这些特性。你不是很傲慢吗?

请仔细阅读。我根本不是这样说的。我想说的是:

如果您想要能够对您的代码进行推理,那么如果您的语言支持我提到的功能,就会容易得多。

如果关于代码的推理对您来说不是那么重要,那么请随意忽略我所说的一切!

问:只关注编程语言的一个方面太有限了。难道其他品质也同样重要吗?

是的,或者他们当然是。在这个问题上,我不是一个绝对主义者,我认为完善的图书馆、好的工具、友好的社区和生态系统的力量等因素也是非常重要的。

但这篇文章的目的是解决我在开始时提到的具体评论,例如:“C#已经拥有了F#的大部分特性,所以我为什么要费心转换呢?”

我非常喜欢动态语言,我最喜欢的语言之一Smalltalk按照我所说的标准是完全不合理的。幸运的是,这篇文章并不是试图说服你哪种语言总体上是“最好的”,而只是讨论这一选择的一个方面。

**问题:不可变的数据结构很慢,会有很多额外的分配。这不会影响性能吗?**。

这篇文章并不是试图解决这些特性对性能的影响(或任何其他方面)。

但这确实是一个合理的问题,哪个应该更优先:代码质量还是性能?这是由你决定的,这取决于上下文。

就我个人而言,我会把安全和质量放在第一位,除非有令人信服的理由不这样做。这里有一个我喜欢的标志:

我在上面说过,这篇文章并不是试图说服你选择一种仅仅基于“合理性”的语言。但事实并非如此。

如果您已经选择了一种静态类型的高级语言,如C#或Java,那么很明显,合理性或类似的东西是您语言决策中的一个重要标准。

在这种情况下,我希望这篇文章中的例子可能会让您更愿意考虑在您选择的平台(.NET或JVM)上使用更“合理”的语言。

坚持原地踏步的理由--当前语言最终会“赶上”--可能纯粹就功能而言是正确的,但是再多的未来增强也不能真正改变OO语言的核心设计决策。您永远不会摆脱空值、可变性,或者必须一直覆盖相等。

F#或Scala/Clojure的好处在于,这些函数替代方案不需要更改您的生态系统,但它们确实会立即提高您的代码质量。

在我看来,与往常的业务成本相比,这是一个相当低的风险。

)我将留下寻找有技能的人才、培训、支持等问题。

.