Lisp 神秘的元组问题





5.00/5 (5投票s)
回复:Lisp 为什么没有流行起来。
引言
Lisp 语言于 1958 年创建时,是一种开创性的语言。它提供了其主要替代品 Fortran 无法比拟的功能。多年来,Lisp 的灵活性使其成为探索新编程语言概念的沃土,而这些概念最终无一例外地被复制并应用于其他语言。然而,Lisp 本身,即使在 Clojure 中进行了现代化改造,也远不如其他语言流行。这是为什么呢?
毫无疑问,Lisp 是强大的。它实现的简洁性和同像性使得可以在一种抽象层面上工作,这种抽象层就像能够轻松地为每项任务编写自己的编程语言一样。我读过的一些文章将这一点与 Lisp 的缺乏吸引力联系起来,声称它“过于强大以至于适得其反”。我部分同意他们的观察——当每个程序员都可以轻易地构建自己的抽象时,编程很容易变得混乱碎片化。然而,我并不认同他们的傲慢。Lisp 并非某种理想化的强大典范,如同创世本身,凡人程序员无法驾驭。不,Lisp 有一个严重的缺点,阻碍了“我们其他人”使用它。
我称 Lisp 最大的缺点为神秘的元组问题。它源于 Lisp 语言习惯性地过度使用列表作为乘积类型,更常见的称呼是元组。在这篇文章中,我将解释这个问题,更流行的语言是如何避免这个问题的,以及如何在没有这个缺点的情况下拥有 Lisp 的强大功能。
这里有一些神秘的元组
(2.3 4.5 2.3) ;; is it a 3d vector (x,y,z)? a list of 3 floats? or something else? ("Fred" "Jones") ;; is this a tuple of (firstname,lastname)? or a list of names?
什么是元组?
维基百科将元组描述为一个有限的有序元素列表。更具体地说,它是一个项目列表,其中元素的精确位置具有意义,类型可能不同,并且单个项目没有逻辑名称。元组通常是不可变的。查阅 Lisp 或 Scheme 参考资料不会找到元组的定义,因为它没有这样的数据类型。Lisp 程序员通常将元组表示为列表。在 Lisp 中,所有元组都是列表,但并非所有列表都是元组,这种说法只有部分是戏谑的。
元组的例子是什么?3D 向量,通常表示为 (x, y, z)。当我们处理 3D 向量时,我们需要分别具体处理 x、y 和 z 的位置。包含 2 个或 4 个元素的 3D 向量是无效的,因为我们编写的代码是专门处理三个值的。对值进行排序或重新排列会破坏数据。3D 向量与其他随机的三个数字三元组之间的操作也会破坏数据。
哪些类型的列表不是元组?任何事物的列表。列表是可变长度的项目列表。大多数列表是*单态*的,这意味着列表中的项目都是同一种*类型*。有时这意味着列表的元素类型完全相同。例如,字符串名称列表('apple','pear')或数字列表(1,2,3,4,5)。有时列表中的元素具有相似的品质但类型不完全相同,例如知道如何绘制自己的代码对象。这称为多态性。很少情况下,列表是异构的,这意味着列表中的项目没有共同的类型或相似性。在这种情况下,处理列表的代码将不得不分别专门处理列表中的每种类型的项目。我们可以说所有列表的一个共同点是,任何将意义赋予列表中位置的代码都是有缺陷的,这使得它们几乎是元组的反面。
神秘的元组问题
查看 Lisp 代码时,很难知道存在哪些元组以及它们中实际包含什么。
在 Lisp 中,不仅习惯于使用列表来表示元组,而且普遍地*到处*都这样做。事实上,一些 Lisp 代码将列表用作唯一的聚合数据类型。代码不仅没有携带任何类型声明来指明特定元素是 3d 向量,而且数据本身甚至不知道它是一个 3d 向量。就 Lisp 而言,它只是一个包含三个数字的列表 (3 4 -3)。构建元组的代码通常不引用元组中各个位置的逻辑名称(例如 x, y, z),因为它不需要。此外,在 Lisp 中,函数经常重复地解构和重构元组,通常不提及元组类型的名称或其字段的逻辑名称。这使得很难知道应该输入什么,输出什么,或者中间的函数实际在做什么。
如果我们的程序只包含一种类型的元组,即 3d 向量,那可能还算可控。如果掺杂五六种元组,就变得很难区分了。如果掺杂几十种元组(比如 Emacs 的代码),那简直就是完全的、彻底的、毫无缓解的元组混乱。
当然,Lisp 还有其他存储元组的方式。它有结构体;它有对象;它有多种类型的结构体和对象。然而,大多数 Lisp 程序员和程序并不经常使用它们,稍后我们将讨论原因。
如果您得知世界上最流行的编程语言编写的程序根本不使用元组,您会感到惊讶吗?它们确实不使用。这包括 C、C++、C# 和 Java 编写的程序,这些语言都围绕某种形式的记录构建。在可以表达元组的流行动态语言中,包括 Python,元组并不是惯用的。大多数 Python 程序理所当然地只将元组用于多返回值,从而防止神秘元组问题蔓延到程序的其余部分。
注意:C/C++ 结构体有点像元组。预初始化的数据语法可以根据字段位置将数据值分配给字段,并且声明中字段的顺序决定了它们在内存中的布局。然而,字段值只能通过字段名读取或设置,这意味着实际上它们更像记录而不是元组。
我们这些“非 Lisp 程序员”并不害怕 Lisp 的强大。我们只是不喜欢那些由未知元组类型构成的难以阅读的混乱代码。我们更喜欢使用记录类型,通常称为结构体。
不过,在我们谈论结构体之前,我们先来谈谈别的东西。
为什么 Lisp 爱好者钟爱列表(而我们其他人不爱)
当然,Lisp 爱好者不认为将所有东西都用列表表示是混乱。他们认为这是美。所有数据都是列表的美,因此所有数据都可以统一操作。无需声明元组结构体的自由,因此能够在每一行代码中轻松生成新元组。可以将任何数据传递给任何转换函数的灵活性,因为一切都是列表。
这种观点并非大多数软件开发的普遍观点,原因有二
第一个原因是,神秘的元组使代码非常难以阅读。当查看 Lisp 函数时,通常很容易看出一个变量是列表——也许是一个元组,或者一个元组列表。不那么容易的是知道元组里面是什么。我们查看列表的来源,通常发现的不是数据的创建,而是来自其他地方的数据转换。所以我们查看该数据的来源,如此反复。在陌生的 Lisp 代码中工作就像考古挖掘,一层层地剥开变化。有时我们不得不运行代码并打印恰好出现的数据,希望它能解释自己是什么。有时它能。有时它不能。
第二个原因是,神秘的元组使代码非常难以修改。当修改列表转换管道的中间部分时,嵌套列表结构的任何更改都可能无意中破坏其他一些代码的预期——而且 Lisp 系统中没有任何东西可以帮助您了解或找到它。您能做的最好的事情就是运行代码,看看它是否会崩溃。希望您也编写了详尽的测试,这样您就不必手动测试所有内容。编写测试是好的。仅仅为了不造成元组混乱而不得不编写测试就没那么好了。
如果这些问题听起来与针对动态语言的某些问题有些相似,那您就对了,因为确实如此。然而,Python 和 Ruby 中的对象具有类结构;它们具有运行时类型。在这些语言中,惯用的编程不会将所有内容存储在元组或列表中。当我们创建一个 Python 3d 向量时,我们通常会将其创建为一个类,包含字段 x、y 和 z。该类型的数据将在运行时知道其类型,并且使用该类型的代码将引用 x、y 和 z。这种结构指导程序员理解、更改和调试代码。像 Lisp 那样,将列表作为编程的基础数据结构,无疑会加剧动态程序的脆弱性。
如果将所有内容都变成列表不是答案,那么答案是什么?嗯,没有万能的免费午餐,但对于我们旅程的下一部分,让我们来谈谈记录。
另一种选择:记录
记录类型,或结构体,是一组(通常无序的)字段,每个字段都由名称标识。由于字段有名称,所以每次访问数据都会携带关于数据含义的一些信息。
在像 Python 和 Javascript 这样的动态编程语言中,这些记录通常存储在哈希表中,要么作为哈希映射的一部分,要么作为最终由哈希映射支持的类定义。Python 类需要定义,而哈希映射则不需要。
在大多数静态类型编程语言中,例如 C、C++、C# 和 Java,类和结构体是记录类型,它们的表示效率高于哈希映射。此外,它们必须明确声明,这带来了额外编码的缺点,但优点是当代码与预期结构不符时,编译器可以发出警告。它还赋予它们名称。
在静态类型推断编程语言中,如 ML、OCaml 和 Irken,记录和对象可以在不定义的情况下创建,更像动态哈希映射——但它们被高效地表示并进行类型检查,更像类或结构体。
注意:在 C 和 C++ 中,`struct` 实际上是有序的,因此它实际上是元组乘积类型和带字段记录类型的混合体。然而,它的顺序只能在数据初始化器中使用,以及在依赖其内存表示时使用。所有访问结构体元素的代码都必须通过其字段名称来引用。在 C# 中,结构体可以被赋予 `[StructLayout(LayoutKind.Sequential)]` 属性,要求其保持顺序,以便与 C 的内存布局匹配。然而,C# 中的数据初始化器必须始终通过构造函数参数顺序完成,并且字段始终通过名称访问。
为什么 Lisp 程序不习惯性地使用结构体?
很难在其他编程语言中找到 Lisp 不存在的抽象,所以 Lisp 当然也有结构体。然而,它们在 Lisp 程序中并不常用。让我们来看看原因。以下是声明我们的 3d 向量类型、创建一个并打印出来的 Lisp 代码
(defstruct vec3d (x 0.0 :type float) (y 0.0 :type float) (z 0.0 :type float)) (define point (make-vec3d :x 3 :y 4 :z -3)) (print (format "(~S,~S,~S)" (vec3d-x point) (vec3d-y point) (vec3d-z point)))
这段代码片段中有几点很突出。首先,我们必须声明什么是结构体。其次,我们创建向量的代码比 (3,4,-3) 冗长且不整洁得多。此外,访问此结构体也十分冗长,并且每次都带着类型名。显然,我们可以看出为什么结构体不是 Lisp 中常见数据结构需求的解决方案。将此与其他语言中的类似设施进行比较
// Javascript point = {x=3, y=4, z=-3}; print(format("(%s,%s,%s)", point.x, point.y, point.z) # Python class Vec3d: def __init__(self,x,y,z): self.x = x; self.y = y; self.z = z point = Vec3d(3, 4, -3) print "(%s,%s,%s)" % (point.x, point.y, point.z) ;; clojure (a Lisp, ha ha!) (def point {:x 3 :y 4 :z -3}) (printf "(%s,%s,%s)" (:x point) (:y point) (:z point))
注:虽然 Clojure 确实有一些处理映射的优秀语法,可以用来表示记录,但不幸的是,在 Clojure 代码中将元组存储在列表中仍然是相当惯用的做法。
另一种选择:带类型元组(变体)
虽然记录是表示元组的一种强大而清晰的方式,但有时一直使用字段名会使程序的含义模糊不清。一些静态类型语言提供了更紧凑、甚至像 Lisp 一样的方式来表示元组,同时保留其类型。它们通常作为代数数据类型的一部分提供,并可在 ML、OCaml 和 Haskell 等语言中使用。在这些语言中,元组具有特定类型(有时称为变体类型),并包含特定乘积的元素类型。例如,我们的 3d 向量可能类型为 (vec3d = float, float, float)。
在许多静态语言中,您必须提前声明类型,这与动态语言相比可能是一个负担。然而,OCaml 和 Irken 有一种特殊的带类型元组,称为多态变体。这些无需声明即可创建,只需指定一个名称。该名称允许编译器对您的元组进行类型检查,并确保您在使用它们时正确解包所有元素,并且*在相同的代码路径上*,使用相同元组类型的实例具有相同的类型。
(* OCaml *) let point = `Vec3d 3. 4. -3. in match point with `Vec3d x y z -> Printf.sprintf "(%f,%f,%f)" x y z ;; Irken (define point (:vec3d 3 4 -3)) (match point with (:vec3d x y z) -> (printf "(" (float x) "," (float y) "," (float z) ")"))
在 Lisp 中模拟变体
如果有人拿走了所有其他编程语言,让我用 Lisp 编程。我会使用 Irken,一个静态类型、类型推断的 ML 和 Lisp 混合体。
如果他们让我用动态 Lisp 编程:我肯定会很难过。我会使用 Clojure。然后我会做 Lisp 程序员最擅长的事情。我会发明自己的抽象——用于明确区分的元组。怎么做?
在 Clojure 中,将元组类型作为第一个元素写入向量是很容易的。此外,在处理值时,使用模式匹配表达式解包元组也很容易。这确保了所有元组都知道它们的类型,并且所有创建或解包它们的地方都提到了它们的类型。它还将提供一些运行时检查,以防止元组意外混淆。它不如 OCaml 或 Irken 的编译时检查那么好,但我会活下去。
;; clojure (def point [:vec3d 3 4 -3]) (match [point] [:vec3d x y z] (printf "(%s,%s,%s)" x y z))
在我的代码中,我会制定风格指南,禁止使用未区分的元组。可悲的是,其他人不太可能使用我的约定,所以我将不得不避免或限制他们那些神秘的元组大杂烩。
结论
我希望你明白为什么我认为 Lisp 相对不流行并不是因为它具有压倒性的力量,而我们这些“凡人”无法驾驭。相反,我认为 Lisp 的致命缺陷在于它习惯性地过度使用存储为列表的未区分元组。尽管 Lisp 具有灵活性,但这正是其流行度滞后的原因,而 Python、Ruby 和 Javascript 等其他动态语言的流行度却飙升。