在过去的18个月里,我一直在脑海中断断续续地玩弄着一个理论上的想法,但我还没有完全大声地表达出来。它涉及编程语言中的所有权语义(OS)或移动语义的概念。从根本上说,本文是对这一概念的批判,并指出这一概念是传统面向对象程序设计的二元性,但适用于不同的领域。
本文中使用的术语定义的一般列表,以最大限度地减少混淆。
(数据)类型是一个值的属性,它编码有关如何对数据值进行操作的信息。
自有价值是一种属于价值所有权等级的价值,这意味着它是由代理人管理的。
范例是一种对编程语言结构模型进行分类的方法;范例是一种解释模型。
面向对象编程(OOP)--通常通过将数据和代码耦合到单个单元中,围绕对象的唯一概念构建程序的范例。
尽管Alan Kay 1创造的术语的原始概念从未按照他的意图使用,但术语面向对象编程(OOP)通常被理解为一种围绕对象概念构建程序的范例,通常是通过将数据和代码耦合到单个单元中来实现的。许多语言支持多种范式,包括面向对象编程范式的方面,但我将它们归类为多范式,而不只是一种面向对象语言。
大多数语言按照Simula传统实现对象和类;大多数著名的OOP语言通过在类定义中定义方法(成员函数)来实现类似的形式。传统上,Java等语言可以单独归类为面向对象的语言。
大多数传统的OOP语言都基于继承的概念,继承是一种从另一个类数据类型派生类数据类型并保留类似信息的机制。大多数人通常认为继承是子类型和通过虚拟方法表(Vtable)进行动态调度的组合。这引发了很多讨论,如果一种语言不支持继承2,那么它是否可以被称为面向对象程序设计(OOP)。
最近,继承已经不再流行,取而代之的是组成3。这主要是由于将类符合严格(单一)代理层次结构的问题,而实际上,事物可以属于许多(如果不是无限的)类别和层次结构,以及我将在整篇文章中讨论的另一个方面。
对OOP 4 5 6 6 7 8 9有很多批评,但我一般的批评是,它把重点从数据结构和算法转移到试图解决类型系统中的问题,而数据结构和算法是程序本质的核心。
由于对象本身被视为具有行为(而不仅仅是类型属性),因此它们实际上被视为程序中的代理。这种思维模式有很多结论,其中很多都会引发问题。
面向对象编程是一种被误解和误用的亚里士多德形而上学,适用于它从未打算建模的领域。
我这样说的意思是,人为地将数据和类型之间的任何/所有关系都遵循人为的代理层次结构,是天真-亚里士多德式形而上学的一种形式。由于编程对象中没有实际的代理,这是一个部分谬误。当程序试图使其具有特定的结构时,如果它不是自然的,那么程序中没有结构比糟糕的结构更有用。
向类/对象添加方法的概念已被证明对许多人很有用。真正的问题是:
对于大多数人来说,我敢打赌,在强调继承而不是组合的语言(如C++或Java)中,方法被视为将函数/过程与数据记录分类和关联的一种方式。采用这种方法有几个原因:
允许将方法作为以主谓宾语方式编写调用的语法糖的形式,例如foo_do_thing(x,y)vs x.do_thing(Y)。
根据经验,我发现长期使用“面向对象”语言的用户最终开始主要使用前两种方法来处理方法。
我不会深入讨论OOP的其他主要方面,如封装、本地保留、多态形式等,因为分层性质是本文关注的基本方面。代理的(线性)层级是主要问题。人们之所以主张组合而不是继承,是因为它使这种线性层次变得扁平,降低了它的影响。它是从名义类型到结构类型的过渡,更灵活,因为许多数据结构和问题具有非线性性质,这是线性方法无法处理的。当试图坚持严格的层次结构类型系统方法时,这会导致许多问题,因为对于大多数问题来说,数据更多的是图形(非线性),而不是树形(线性)。这种严格的层次结构也发生在对象级别的封装中,即消息/引用的严格层次结构;这种层次结构性质源于概念代理本身,继承不是根本原因。
注:继承并不全是坏事,在现实生活中确实有很多实际用途,但在使用它们之前必须知道这些成本,就像使用任何工具一样。
注:线性关系与数据结构本身有关,而与算法无关。
C++11引入了移动语义或所有权语义(OS)的概念,这是一种通过复制构造函数最小化数据复制的方法。它利用增加的r值引用(T&;&;)概念来实现这一点。然而,这个概念开始被用于远远超出其基本目的的用途。这个概念增加了“移动”对象而不是“复制”对象的高级抽象。从物理上讲,计算机只会复制,这种高层次的抽象,把对象当作“真实的对象”来对待,这并不是实际发生的事情。将它们视为“真实对象”也是一个范畴错误,因为“真实对象”和“编程对象”在本体论上几乎没有联系。当一个值或对象被“移动”时,这意味着该对象的资源的责任已经转移到另一个对象或环境主体。在这种情况下,所有权/移动语义基本上是基于跟踪值使用情况的值的职责。
在这种代理模式下,代理的竞技场可以采取多种形式,如区块、程序主体或聚合价值。因此,一些拥有价值的人还拥有其他价值,因此价值可能具有代理性。
如果我们把所有权语义学称为一种范式,那就是以一种层次分明的方式围绕价值的责任进行定位,把重点从数据结构和算法转移到这个责任体系上。
责任和所有权的概念与现实世界的对应概念相似,因为拥有某种东西意味着对它拥有独家使用和全部责任。
Rust是一种多范例编程语言,但其核心是一种面向所有权的语言。铁锈中的每一样东西都有一个“所有权”和与之相关的生命周期的概念。Ruust的设计初衷是要首先做到“安全”,尤其是在并发性方面。Rust在理念和风格上源自C++家族,但使用了更加侧重于限定符的声明语法和来自ML家族函数式语言的许多概念。
理论上,生命周期与所有权是正交的,但在实践中,它们通常是内在耦合的。在本文中,我不会讨论基于对象的生存期问题。
下面的Rust代码可以用来演示不同捕获对象(如let语句)之间的责任转移:
Pub struct foo{value:i32,}fn main(){let foo=foo{value:123};let bar=foo;//`foo`的责任转移到`bar`println!(";{}";,foo.value);//错误:使用移动值:`foo.value`println!(";{}";,bar.value);}。
默认情况下,Ruust是一种不可变的语言,可以选择使用mut实现可变性。不变性对逻辑的数学证明有很大帮助,因为事物可以很容易地“扁平化”,然而几乎所有的计算机从根本上都是可变的东西,即使不变性的抽象是一个有用的工具。因此,所有权语义系统需要更多的规则来考虑可变性,即通过引用添加“借用”的概念。借用检查器的一般规则如下:
在使用Rust(或在C++11中充分利用语义)时,大多数人会定期与借阅检查器(特别是新手或在不同语言之间交换的人)进行竞争。很多人已经找到了减少这些问题的方法:
保持小块,小结构,等等--这就缩小了代理的范围,从而减少了它必须承担的责任。
最小化结构中的自引用,即很难使用引用实现类似图的数据结构。
本质上,所有这些方法都以某种形式(如果不是完全的话)绕过了借入检查器,特别是索引/句柄的使用。前三种方法是扁平化(线性)责任层次。
注:所有权语义确实有很多实际用例,可以用来证明许多问题的安全性,特别是减少程序中的漏洞。这就是Mozilla开发Rust的主要目的。通过沙箱、数据竞争、网络和其他并发问题,Web浏览器需要是非常安全的程序。当涉及到程序的安全性和健壮性时,能够在编译时证明某些事情是非常有用的。然而,正如我已经说过的,由于操作系统的线性性质,如果不诉诸不安全或完全绕过借入检查器的另一种方式,它无法解决一系列其他问题。
所有权语义是仿射子结构类型系统1112的一种形式,这意味着它们基本上由线性逻辑描述,并解释了为什么它难以表达非线性问题。因此,所有权语义和借用检查器基本上是线性树(层次结构),而不是其底层形式逻辑所描述的非线性图。现实生活中的许多数据结构和问题基本上都是非线性的,而线性方法无法处理这些问题。
在引入移动语义的C++11中,STL包含了“智能指针”的概念,每个指针都有不同的子结构逻辑。
如果您想更多地了解应用于Rust语言的所有权语义的基本逻辑,我推荐阅读这篇使用形式化数学解释该逻辑的文章:Oxide:the Essence of Rust(arxiv:1903.00982)。
在面向对象的情况下,值/对象是代理。在操作系统的情况下,代理是对其负责的任何东西(例如,另一个对象、函数、块等)。两者都有非常严格和单一的线性值层次结构。
两者在本质上都是单一的,因为它们处理的是单一形式的价值,而不是一组价值。它们(传统上)都是非常层次分明的,并且把重点放在系统作为控制过程的一种方式,而不是指导过程的算法。对象和拥有值基本上是“名词”,但程序是“动词”。
处理奇异值可能非常有用,但并不是所有东西都是值。有些东西基本上是“非值”的,例如指令/控制流/声明。这是一种类似于面向对象程序设计(OOP)的整体世界观,即一切都必须是X(或产生X)。
所有权语义与终生语义是分开的,但它们都需要在更复杂的问题中有用,而且通常是耦合的;这自然是因为基于单值的性质。
从这些文章中,许多其他人认为,像Rust这样的语言可以解决许多这样的问题,比如释放后使用。然而,这不一定是真的。所有权语义可以解决一些问题,比如释放后使用,这是正确的,但这并不意味着它可以解决大部分问题。即使像释放后使用这样的事情是安全/内存错误,它们通常也是另一个更大问题的症状,而不是它本身就是根本原因。
阅读这篇文章时,许多人会问一件事:“如果所有权语义不好,你建议用什么来替代呢?”
通常,大多数困难问题不能在编译时解决;正因为如此,在语言的类型系统中添加越来越多的概念不会有任何帮助,除非增加额外的成本。这并不意味着所有权语义不好,但不能解决该领域的许多问题。
许多与责任相关的问题都可以通过程序中的“子系统”形式得到更好的解决,这些“子系统”处理一组“事物”,并给出“事物”的句柄,而不是直接引用。这与许多人已经使用的通过使用索引/句柄绕过借入检查器的方法有关。句柄可以包含比单数多得多的信息。一种常见的方法是将世代号与索引一起存储在句柄中。如果一代死了,但是要求使用句柄,那么子系统可以发出一个虚拟的前哨数值并报告一个错误。
其他方法是首先减少对责任的需求。通过保持数据结构POD的可复制性和零值的实用性,可以帮助您改变思考手头问题的方式并简化代码。它更强调数据和算法本身,而不是对象和类型之间的关系。
所有权语义是一种以层次化的方式处理值的责任的方式,它以层次化的方式围绕值的责任进行处理。这导致了责任的(线性)价值层次结构,其中工程师对价值负责。所有权语义的问题与传统的面向对象程序设计(OOP)具有相同的结构性问题,这导致了行为的(线性)值层次结构,其中值充当代理。
所有权语义对于某些问题来说是一个有用的工具,但由于其内在的线性逻辑,它们不能用来表达非线性问题,这使得人们试图完全绕过这个概念。
“我认为物体就像网络上的生物细胞和/或单独的计算机,只能通过消息进行交流(所以消息传递一开始就是这样--花了一段时间才知道如何用一种编程语言高效地传递消息,使之变得有用)。”--艾伦·凯,2003年,http://userpage.fu-berlin.de/~ram/pub/pub_jf47ht81Ht/doc_kay_oop_en↩︎。
Go编程语言不支持继承。然而,根据我的定义,Go是一种面向对象的语言,但它是围绕(隐式)接口(类型类或结构类型的一种形式)设计的,作为组成对象的一种方式,方法可以应用于任何用户定义的类型;而不仅仅是记录类型。↩︎。
人们习惯于绕过借入检查器来减少争执,这意味着人们刚刚找到了一种方法来应对它施加的限制。↩︎。
在写这篇文章的时候,我没有意识到这已经被开发出来了,我意外地重新发现了子结构类型系统和线性逻辑,并有了自己的术语。但是,它更适用于更常用的术语。↩︎