Ela,函数式编程语言






4.92/5 (30投票s)
对完全用 .NET/C# 实现的解释型函数式编程语言的描述。
引言
Ela 是一种具有动态类型和受 ML/Haskell 启发语法的函数式编程语言。动态指一种类型系统,它使用“不到最后一刻不确定”的类型推断算法。换句话说,Ela 中的类型信息在运行时可用,这有其缺点,但为程序员提供了很大的灵活性。函数式意味着 Ela 中的所有操作都可以看作是函数组合。你当然可以在 Ela 中编写命令式代码——就像你可以在几乎所有函数式语言(包括 Haskell)中一样——然而,在大多数情况下,Ela 并不是实现这一点的正确工具。
语言的核心部分是纯粹的。Ela 不支持状态突变——它甚至没有变量。该语言主要面向纯函数式编程风格。有状态编程也是一种可能性,但所有副作用代码只能通过外部函数(例如 C#)呈现。
Ela 具有许多函数式编程语言的特点,例如:万物皆表达式、一流和高阶函数、名称和数据结构的不可变性、模式匹配、代数类型等等。目前,Ela 配有 Ela 控制台实用程序,可用于执行和编译 Ela 代码,并完全支持交互模式。此外,Windows 上还提供图形开发环境。
在本文中,我将简要概述 Ela 的实现和一些核心语言特性。
实现
解析器
Ela 完全用 C# 编写。其当前实现由四个松散耦合的主要组件组成:解析器、编译器、链接器和虚拟机。
解析器使用 CoCo/R 编写,CoCo/R 可以生成 LL(1) 解析器。CoCo/R 确实提供了一些额外的工具来解决不属于 LL(1) 策略的冲突;然而,这些工具非常有限。幸运的是,Ela 语法非常直接。该语言大多缺乏任何依赖上下文的构造(Ela 本身所用语言中有很多这种构造),因此,Ela 语法非常符合 LL(1) 要求。
如果您以前没有听说过解析器生成器,这些工具可以将手动实现解析器这样相当复杂的任务变成相对简单的任务。它们背后的思想如下:您提供声明性语言语法作为输入,并获得生成的解析器作为输出。
语法由标记(例如标识符、整数、字符串等)和描述特定语言构造的产生式组成。
例如,Ela 中众所周知的条件运算符是这样的
IfExpr =
"if" Expr "then" Expr "else" Expr.
LL(1) 描述了 CoCo/R 可以创建的一种特定类型的解析器。这种解析器从左到右工作,并且可以向前看一个符号。它确实引入了某些限制;然而,正如我之前提到的,Ela 语法不是太复杂,因此 Ela 完全可以使用 LL(1) 解析器。同时,CoCo/R 生成的解析器速度极快,整个工具使用起来相对容易,这使得 CoCo/R 成为在 C# 中生成解析器的好选择。
除了形式语言语法,解析器定义还包含所谓的语义动作——这些是您创建表示代码的对象模型(抽象语法树,AST)的地方。Ela 使用其自己的特定于语言的 AST,它反映了语言的一些特点,例如包括整个程序在内的所有内容都是表达式。
与一些通过遍历 AST 解释代码的语言实现(例如 Ruby 1.8)不同,Ela 采用稍微不同的方式,并根据来自解析器的对象模型生成中间字节码。
这就是 Ela 编译器所负责的。
编译器
Ela 编译器使用递归下降来处理 AST 并生成适当的字节码指令。除此之外,它还生成调试信息,用于生成异常堆栈跟踪等任务。
基本上,编译器负责很多事情——它计算评估堆栈的大小,以及用于存储非临时对象的“堆”的大小。此外,Ela 支持像 C 或 Haskell 这样的词法作用域,这也是 Ela 编译器所做的事情——它跟踪名称的声明,允许来自封闭作用域的名称相互遮蔽等等。
Ela 字节码(称为 EIL)包含大约一百条指令,并受到 MSIL 的启发。然而,与或多或少是面向对象的汇编语言的 MSIL 不同,EIL 不提供任何特定的 OOP 支持,而是倾向于函数式范式,并提供一组高级指令,可用于创建闭包、将函数视为一流值等。
EIL 是一种基于堆栈的汇编语言。例如,像 (2 + 2) * 3 这样的简单表达式将被编译成以下 EIL
PushI4 2
PushI4 2
Add
PushI4 3
Mul
我希望这里发生的事情很清楚。编译器从左到右处理表达式并将特定值推送到堆栈上。Add
(加法)和 Mul
(乘法)操作分别从堆栈中弹出两个值,执行计算,并将结果推回堆栈。一旦 Add
指令执行,堆栈上只有一个值 4;Mul
指令执行后也一样,它将整个表达式评估的结果推送到堆栈上。
所有 EIL 指令都具有固定的堆栈行为(例如,此指令对堆栈执行什么更改——例如 Add
,它弹出两个值并将一个值推回堆栈)。此外,EIL 指令具有严格的字节大小。这基本上是字节码之所以如此命名的原因。在 EIL 中,指令的长度可以是一个字节(这适用于所有没有参数的指令,如 Add
、Mul
、Pop
等,此字节保留用于指令代码本身)或五个字节(这适用于带有参数的指令,如 PushI4
,它将整数值推送到堆栈上,并以一个四字节整数作为其参数)。
您可能想知道 Ela 如何处理不适合四个字节的数据类型——它甚至有吗?让我们看看当您尝试编译 64 位整数文字 0x7fffffffffffffffL 时会生成什么代码
PushI4 -1
PushI4 2147483647
NewI8
如您所见,64位整数值分两次推送到堆栈,然后使用内置的 NewI8
指令进行组装。一个更复杂的例子——在 Ela 中创建形式为 {x = 1, y = 2} 的记录(记录在 C# 中有点像匿名类型)
Newrec 2
PushI4 1
Pushstr "x"
Reccons 0
PushI4 2
Pushstr "y"
Reccons 1
正如你所看到的,这是一长串指令——第一个创建了一个空的记录,并为两个字段预留了槽位,其余的通过将字段的值和名称推送到堆栈上来初始化特定的字段。Reccons
是一个用于记录构造的内置指令。它接受一个记录字段索引作为其参数,并从堆栈顶部弹出字段名称和字段值。
好的,我们已经生成了字节码。但现在我们需要解释它——这就是虚拟机所负责的。
虚拟机
Ela 有一个基于堆栈的虚拟机(这很明显,因为你绝对不能使用基于寄存器的虚拟机来解释面向堆栈的字节码)。Ela VM 是一个确定性有限状态机。它是线程安全的——Ela 函数可以在不同的物理线程中执行,这是一个完全合法的场景。此外,与其他一些基于堆栈的 VM 实现不同,Ela 机器使用两个堆栈——一个用于执行所有计算的评估堆栈,以及一个存储描述当前正在执行的函数的结构的调用堆栈。
除了几个高级指令(包括函数调用、内置数据类型创建等)之外,Ela 的大多数字节码命令都非常原子化,因此,Ela 机器令人惊讶地比编译器更简单。
让我们看看它是如何工作的。例如,我们需要一个简单的虚拟机,它只能操作整数并支持两个操作——加法和乘法。它可能看起来像这样
public int Eval(Op[] ops, int[] args)
{
var offset = 0;
var stack = new Stack<Int32>();
for (;;)
{
var op = ops[offset];
var arg = args[offset];
switch (op)
{
case Op.PushI4:
stack.Push(arg);
break;
case Op.Add:
{
var right = stack.Pop();
var left = stack.Pop();
stack.Push(left + right);
}
break;
case Op.Mul:
{
var right = stack.Pop();
var left = stack.Pop();
stack.Push(left * right);
}
break;
case Op.Stop:
return stack.Pop();
break;
}
}
}
就是这样。正如你所看到的,我们有一个函数,它接受一个字节码指令数组(为了方便起见,以枚举形式呈现)和一个指令参数数组。为了简化指令解码,这些数组的长度总是相同的。如果一个指令没有参数,那么参数数组中仍然有一个槽位,其中包含零。在这个例子中,我们也只使用一个评估堆栈(Ela 为了性能起见有自己的堆栈实现,但这里我们使用 System.Collections.Generic
中的标准类)。Stop
指令应该是我们程序中最后一个指令——这个指令终止执行并返回当前在堆栈顶部的那个值(我们假设一个正确的程序应该始终有一个值)。多亏了 Stop
指令,函数 Eval
的核心部分可以实现为一个无限循环,在每次迭代中都不需要评估任何条件,这也稍微提高了性能。
就是这样。很简单,不是吗?
链接器
链接器是我在这里要描述的最后一个组件。它比之前的组件复杂性较低,其唯一目的是将独立的 Ela 模块组装成一个实际执行的完整程序集。
Ela 中的每个模块都可以使用 open
指令引用其他模块,例如:
open math
res = sum 2 2
链接器的任务是定位所有引用的模块,构建它们,并最终将所有内容组合成一个可供执行的单一代码程序集。
Ela 链接器使用另外两个组件——解析器和编译器——来构建 Ela 模块。然而,它也支持从二进制(或所谓的对象)文件中反序列化 Ela 字节码。如果它发现目标直接包含一个对象文件,它将尝试读取它而不是解析和编译原始代码。在许多情况下,这可以显著提高构建性能。
嵌入 Ela
您可以在 .NET 应用程序中使用 Ela 解释器。为此,您只需要引用一个小的库——Ela.dll。
您可以直接从代码中将解析器和编译器作为独立组件使用,但通常您只需要一个链接器和一个 Ela 机器来执行 Ela 代码。
Ela 提供了两种链接器实现。名为 ElaIncrementalLinker
的链接器用于支持交互模式。它允许您逐块构建和执行 Ela 代码。如果您想以字符串表示形式评估 Ela 代码,增量链接器也很有用。如果这不符合您的需求,您可以使用常规的 Ela 链接器。以下是一个 C# 示例,演示如何从字符串执行 Ela 代码
var l = new ElaIncrementalLinker(new LinkerOptions(), CompilerOptions.Default());
l.SetSource(elaSourceCode);
var res = l.Build();
if (res.Success)
{
var vm = new ElaMachine(res.Assembly);
vm.Run();
}
在许多情况下,您可能需要为 Ela 代码提供一些参数。这是一个 C# 中 Eval
函数的完整示例,它使用匿名类来捕获参数及其名称
public static object Eval(string source, object args)
{
var l = new ElaIncrementalLinker(new LinkerOptions(), CompilerOptions.Default());
foreach (var pi in args.GetType().GetProperties())
l.AddArgument(pi.Name, pi.GetValue(args, null));
l.SetSource(source);
var res = l.Build();
if (res.Success)
{
var vm = new ElaMachine(res.Assembly);
return vm.Run().ReturnValue.AsObject();
}
else
{
var sb = new StringBuilder();
foreach (var m in res.Messages)
sb.AppendLine(m.ToString());
throw new ElaTranslationException(sb.ToString());
}
}
//sample usage
var r = Eval("fun x y", new {
fun = ElaFunction.Create<int,int,int>((x,y) => x + y),
x = 2,
y = 4
});
你也可以在 C#(或任何其他 .NET 语言)中创建 Ela 模块。这是一个简单模块的例子
public class MyMathModule : Ela.Linking.ForeignModule
{
public override void Initialize()
{
base.Add<Int32,Int32,Int32>("rnd", Randomize);
}
public int Randomize(int seed, int max)
{
var rnd = new Random(seed);
return rnd.next(max);
}
}
这是您需要在 Ela 中使此模块可用的操作
var l = new ElaIncrementalLinker(CompilerOptions.Default, new LinkerOptions());
l.ModuleResolve += (o,e) => {
if (e.Module.ModuleName == "mymath")
e.AddModule(new MathModule());
};
l.SetSource(source);
var res = l.Build();
现在你可以在 Ela 中无缝使用这个模块了
open mymath
r = rnd 0 42
您还可以将模块编译为常规 .NET 程序集,引用 Ela.dll,并指定以下属性
[assembly:ElaModule("mymath", typeof(MyMathModule)]
现在您不必手动将此模块添加到模块集合中。Ela 链接器无需您的帮助即可找到它。但您需要在 open 指令中指定 DLL 名称,例如:open math#mymathdll。
独特功能
语法
正如我之前提到的,Ela 语法深受 ML 语言家族和 Haskell 的启发。Ela 使用基于布局(或缩进驱动)的语法,如 F# 或 Haskell。
Ela 的顶层可以包含名称声明、模块包含指令,甚至正则表达式。例如,以下代码是完全正确的
x = 2
y = 2
x + y
基本上,整个 Ela 程序可以看作是一个表达式(例如,5、"Hello, world!"、2 + 2 都是有效的 Ela 程序)。
正如你所看到的,名称的声明不需要任何特定的关键字。函数也是如此
sum x y = x + y
doubleMe x = x + x
Ela 完全支持模式匹配,这可以描述为一个老式的“switch/case”构造的增强版——它能够匹配任何值,并在单个表达式中比较和绑定名称
xs = [1,2,3,4]
x = match xs with
[1,2,x,y] = x + y
_ = 0
除了常规的 match 表达式,Ela 还支持通过模式匹配定义函数——与 Haskell 中使用的非常相似。下面是 Ela 中实现的著名 map
函数的示例
map f (x::xs) = f x :: map f xs
map _ [] = []
可以使用 let
关键字声明局部名称。还有另一种引入局部名称的选项——where
构造。下面是 Ela 中斐波那契函数的尾递归实现
fib = fib' 0 1
where fib' a b 0 = a
fib' a b n = fib' b (a + b) (n - 1)
where
绑定并不总是等同于 let
。例如,where
允许您声明作用域限定为模式匹配构造中特定条目的名称
filter _ [] = []
filter f (x::xs) | r = x :: filter f xs
| else = filter f xs
where r = f x
您还可以通过将声明缩进到同一级别来声明多个名称(这适用于 let
和 where
绑定)
x + y
where x = 2
y = 3
let x = 2
y = 3
in x + y
所有绑定(局部和顶层)都是相互递归的
take (x::xs) = x :: skip xs
take [] = []
skip (_::xs) = take xs
skip [] = []
以下代码也同样有效sum x = x + y
y = 2
当然,在本节中描述 Ela 语法的全部特点是很困难的,尤其是如果您不了解任何 ML 风格的语言。对于那些想了解更多的人,我建议阅读一本关于 Ela 的书,它更详细地介绍了语法。
柯里化函数
Ela 中的所有函数都只能接受一个参数。别慌——这不是 Ela 的奇怪限制,而是函数式语言的典型行为。
Ela 中的函数应用操作是函数及其参数的简单并置。函数总是只应用于一个参数。此外,函数应用在语言中具有最高的优先级之一,并且是左结合的。因此,当您看到诸如 sum 2 3 这样的代码时,这是
- 一个有效的 Ela 代码,并且是
- 不是一个带有两个参数的函数调用,而是两个函数调用。
这里,我们用一个参数 2 调用 sum
函数,假设这个函数调用返回另一个函数,然后用参数 3 再次调用它。
它实际上揭示了 Ela 处理多参数函数的方式。这些函数基本上是返回其他函数(也可能返回其他函数,依此类推)的函数。例如,我们的 sum
函数可以使用匿名函数语法定义如下:
sum = \x -> \y -> x + y
这相当于 C# 中的以下代码
Func<Int32,<Func<Int32,Int32>> sum(int x)
{
return x => y => x + y;
}
当然,以这种方式定义函数并不总是方便的——这就是 Ela 支持特殊语法糖的原因
sum x y = x + y //This is fully equivalent to \x -> \y -> x + y
像上面的 sum
函数这样的函数被称为柯里化函数。您可能以前听说过这个概念。柯里化函数让您可以部分应用函数。由于我们所有的函数都只是嵌套闭包,因此在调用它们时不必指定所有参数。像 sum 2 这样的调用是完全合法的,它会返回一个接受一个参数的新函数,该函数可以将此参数与数字 2 相加。
此特性通常称为部分应用,它对函数式编程范式至关重要。
中缀、前缀、后缀
这三者是用于函数调用的表示法。大多数语言使用前缀表示法,即函数放在其参数之前。对于运算符,您通常使用中缀表示法,即运算符放在其参数之间。后缀表示法不那么常见——它假设函数放在其参数之后。
前缀表示法在 Ela 中被广泛使用,并且是默认的。然而,有时使用中缀形式更具视觉效果——即将函数放在其参数之间。这里的技巧是——在 Ela 中,即使是常规函数也可以使用中缀形式调用。
在几种情况下,这种可能性会很有用。假设我们有一个 elem
函数。这个函数接受一个元素、一个列表,并测试给定元素是否存在于给定列表中。这个函数可以这样定义
any f [] = false
any f (x::xs) = f x or any f xs
elem x = any (==x)
好的,但是这个 elem
函数有什么特别之处呢?大多没有什么。唯一的例外是,当 elem
的应用以中缀形式编写时可能更容易阅读
elem 5 [1..100]
42 `elem` [1..100]
然而,函数并不是唯一可以从前缀形式转换为中缀形式的实体。默认情况下使用中缀形式应用的运算符也可以像函数一样调用
res1 = 2 + 2
res2 = (+) 2 2
最后——你也可以使用后缀形式应用函数和运算符。后缀意味着参数放置在函数或运算符名称之前。它可能看起来像这样
isEven x = x % 2 == 0
res1 = 12 `isEven`
res2 = 13 `isEven`
运算符也一样
f = (2+) //this is a partial application of + operator
sum2 = (2+)
res = sum2 3 //the result is 5
后缀形式的支持对于运算符来说非常重要,因为它揭示了一种非常方便的部分应用运算符的方式。如果使用后缀形式部分应用运算符,则第一个参数会被“固定”。如果使用前缀形式,则第二个参数会被“固定”
div1 = (5-)
res1 = div1 3 //the result is 2
div2 = (-5) //this is equivalent to: (-) 5
res2 = div2 7 //the result is 2
所有这些技巧也适用于常规函数;然而,它们在运算符中更常见。
因此,凭借所有这些应用形式的多样性以及它们之间切换的能力,我们最终得出结论,运算符和函数之间没有真正的区别。或者更准确地说——运算符是默认使用不同调用约定的函数。
对于大多数标准 Ela 运算符来说,这确实是事实。这又引出了另一个结论:如果差异基本不存在,为什么不让用户定义自己的运算符呢?这在 Ela 中是可能的
//Safe division function
x /. 0 = None
x /. y = Some (x/y)
res = 12 /. 0//take the element with index 2 from the array
Types
Ela 自带丰富且可扩展的类型集合。Ela 有四种数值类型、字符串、字符、不可变链表、元组、记录、变体等等。Ela 标准库还提供了额外的类型。
数值类型包括 32 位和 64 位整数,以及 32 位和 64 位浮点数
i4 = 42
i8 = 42L
r4 = 12.2
r8 = 123.12D
字符串和字符使用 C 风格的字面量和转义码
str = "Hello,\r\n\"Dr. Smith\"!"
ch = str:0
ch2 = '\t'
单链表可以使用列表字面量或列表构造运算符 ::(类似于 F# 中使用的)来构造
lst = [1,2,3]
lst2 = 1::2::3::[]
元组是元素的分组序列,如果您想从函数中获取多个值,它们会很有用
tup = (1,2,3,4)
res = (1,2) + (3,4) //equals to (4,6)
记录是元组,它提供了通过名称访问元素的可能性
let rec = { x = 2, y = 42, str = "string value" }
rec.y
还有许多其他数据类型。
关于 Ela 类型系统需要知道的关键是,Ela 中的大多数标准操作都是多态的。
正如您在上面看到的,对元组执行算术运算是完全合法的。此外,Ela 中的字符串实现为索引数组(类似于 .NET Framework),但您也可以像列表一样折叠它们
foldl f z (x::xs) = foldl f (f z x) xs
foldl _ z [] = z
reverse = foldl (flip (::)) []
revStr = reverse "Hello, world!"
之所以有效,是因为 Ela 提供了一种类似于 Haskell 中类型类概念的支持。但我们稍后会讨论它。变体
多态变体是 Ela 中的另一种数据类型。它的名称听起来可能有些复杂,但概念本身非常直接。这个想法来自 OCaml 语言,但 Ela 的方法有所不同。
多态变体只是为您提供了一种“标记”值的方式
tagged = Even 12
你可以把它们想象成一种工具,它允许你将一些额外的信息与一个值关联起来,并进一步使用这些信息——例如,在模式匹配中
res = match tagged with
Even x = "The number is even!"
Odd x = "The number is odd!"
_ = "Something is wrong here..."
thunk 和惰性列表
Thunk 是一种允许您进行惰性计算的工具。Thunk 是一个带有前导“&”的特殊表达式,它不会立即求值,而只在实际需要其值时才求值
t = & 2 + 2
res = t * 2
在上面的例子中,名称 t
没有用表达式 2 + 2
的求值结果初始化。相反,Ela 创建了一个隐藏的闭包,其中包含上述计算。这个闭包在第一次需要 thunk 的值时被调用——之后,该值被记忆,并且下次您引用 t
变量时不再执行计算。
您可以通过传递一个计算值的函数而不是实际值来达到类似的行为,但是 thunk 在这里有两个独特的特点:首先,它们在常规函数显然没有的情况下执行记忆化,其次,它们可以在您的代码中透明地使用,如您从上面的代码示例中可以看到的。
这意味着您可以实现一个函数,它对其参数执行一些操作,例如简单的算术运算,您可以将实际值或 thunk 传递给这个函数——它们都将完美地工作。
Ela 中的惰性列表是通过使用 thunk 来代替列表尾部构造的。下面是一个生成无限整数列表的函数的小例子
ints n = nn :: (& ints nn)
where nn = n + 1
res = ints 0
范围和列表推导式
Ela 中的范围是算术序列,可用于创建列表。您只需指定序列中的第一个元素、最后一个元素和(可选地)第二个元素,该元素将用于计算步长
r1 = [1..5] //[1,2,3,4,5]
r2 = [1,3..10] //[1,3,5,7,9]
r3 = [100,87..1] //[100,87,74,61,48,35,22,9]
您还可以通过省略最后一个序列元素来使用范围生成无限列表
inf1 = [1..] //[1,2,3,4,5...]
inf2 = [3,2..] //[3,2,1,0,-1,-2..]
Ela 中的列表推导式类似于经典的数学集合构造器表示法。它们可以通过提供选择条件和投影代码,从现有序列生成新序列。您还可以在列表推导式中进行模式匹配
xs = [x + y \\ (x,y) <- [(1,2)..(4,5)] | x > 2] //The result is [7,9]
在这里,我们取一个元组列表,只选择第一个元素大于2的元组,然后返回一个新列表,其元素是元组元素的总和。想象一下,用命令式语言实现同样的功能需要编写多少代码。
一流模块
模块在 Ela 中是一等值——您可以将它们作为参数传递给函数,等等
open math
open symbolMath
doSum x y mod = mod.sum x y
res1 = math <| doSum 2 2
res2 = symbolMath <| doSum 2 2
用户类型
Ela 还提供了定义自己的数据类型的能力,如下所示
type foobar
where foo x = Foo x
bar x = Bar x
事实上,所有标准 Ela 类型,如整数、浮点数、字符串等,都在 prelude 模块(这是一个在其余代码之前执行的初始化脚本)中以相同的方式声明。
类型声明由类型名称和一组类型构造函数组成,这些构造函数可以是函数或常量。表示类型实例的典型方式是通过变体,如上面的示例。类型实例可以使用模式匹配进行分析,如下所示
fb = foo 42
(Foo x) = fb
x //Evaluates to 42
当然,你也可以用变体做同样的事情。在这一点上,用户类型似乎是语言中多余的特性。但等等,它们的存在是有原因的。
类
好的,这是一个冗长的讨论,但我会尽量简短,所以让我们直接进入主题。
首先,这与面向对象编程无关。Ela 中的“类”可以被描述为“操作类”。它可以与 C# 中的接口进行比较,并与它们共享一些特性(例如,它不允许提供实现),但在许多其他方面有所不同。
首先,Ela 中的类用于声明全局绑定。假设我们声明了一个类,如下所示:
class Mapable a
where gmap _->a
现在我们有一个全局函数 gmap,它与常规 Ela 函数没有区别——它是一等值,是柯里化的,等等。事实上,它确实有一点不同。运行时环境允许这个函数有多个实现。
要提供一个实现,应该为特定类型编写一个类的实例。如果尝试在没有正确实例的情况下调用 gmap 函数,将会得到运行时错误
gmap (*2) [1..10]
//Error ELA820: Function 'gmap' is not implemented for 'list'.
为了使这段代码工作,我们必须为类型列表实现一个实例
instance Mapable list
where gmap f (x::xs) = f x :: gmap f xs
gmap _ [] = []
gmap (*2) [1..10] //[2,4,6,8,10,12,14,16,18,20]
它与常规的 map 函数没有太大区别,但一旦 gmap 被定义为类成员,我们就可以很容易地为它实现另一个实例——针对不同的数据类型,例如元组
instance Mapable tuple
where gmap f tup = map 0
where len = length tup
map n | n == len - 1 = (e,)
| else = (e,) ++ map (n+1)
where e = f (tup:n)
gmap (*2) (1,2,3,4,5) //(2,4,6,8,10)
我希望现在你能看到与面向对象方法的一些重要区别。
首先,函数和数据是分离的,因此您不会丢失成员函数中常规函数的任何属性。其次,在像 C# 这样的语言中,您必须在定义类型时实现所有接口,并且显然不可能“添加”接口实现到已经存在的类型,例如标准类型。Ela 中的类,正如您从上面的代码示例中看到的,没有这样的限制。您可以以与为自定义类型编写实例相同的方式为内置类型编写实例。最后,Ela 中成员函数的调度规则是不同的。
您可能已经注意到,Mapable 类中 gmap 函数的声明旁边有一个奇怪的签名 "_->a"。此签名用于指定函数的重载规则。其中的“a”元素是一个类型参数,可以出现在函数签名的任何位置——次数不限,但至少一次。“_”占位符表示此处接受任何值。
在 C#(和许多其他面向对象语言中)中,函数是根据第一个参数的类型在运行时选择的(然后作为“this”参数隐式传递给该函数)。Ela 在这里再次更灵活,因为它允许您为每个成员函数指定重载规则。(事实上,Ela 中基于第一个参数的重载根本不起作用。Ela 中的所有函数都是柯里化的,这自然会影响函数参数的顺序。例如,我们示例中的 gmap 函数接受一个投影函数作为它的第一个参数,一个列表作为第二个参数(而在 C# 中,您可能会颠倒参数顺序)。这允许通过只提供一个投影函数来部分应用 gmap,例如:gmap (*2))。
Ela 预定义模块中的大多数标准函数实际上都是类成员。例如,有类 Eq(定义相等函数)、Ord(比较函数)、Num(算术函数)等。您总是可以声明自己的类或为任何类型(包括内置类型)实现标准类。
如果您有兴趣,可以在语言参考中找到有关类的更多信息。
基准测试
我花了很多时间思考什么是 Ela 的最佳测试。Ela 是一种函数式语言,因此我们应该有一个函数式风格的测试。找到一个好的候选者与 Ela 进行比较绝非易事。Ela 并非真正用于编写命令式代码,而大多数流行的动态语言主要是命令式的(对函数式编程范式有一些,通常是有限的支持)。我相信,仅仅为了进行速度比较而在 Ela 中模仿命令式代码(顺便说一下,这并不总是微不足道的)是没有意义的。为什么要对你永远不会编写的代码进行基准测试?
第二个问题是,我绝对希望 Ela 表现出最好的状态。然而,Ela 并不是最快的解释型动态语言,但也不是最慢的。所以,很想将 Ela 的执行速度与一种已知不快的语言进行比较。比如——无意冒犯——Ruby。但 Ruby 目前没有自己的虚拟机,测试将不公平。
所以我决定选择 Python。
请理解我在这里所做的并非严格意义上的基准测试。我只是试图通过比较来展示 Ela 的执行速度。实际上 Python 比 Ela 快——这不足为奇,因为 Python 是用 C 编写的,C 往往比 C# 和 JIT 编译器生成更快的代码。但在大多数情况下,您只有在编写相当底层的命令式代码时才会注意到这一点。而当涉及到函数式代码时,情况就更有趣了。
好的,我们开始吧。我使用的是 Core i7 620M CPU 和 Windows 7。Ela 0.11 和 Python 2.7。Python 和 Ela 都运行在 32 位模式下。我每个测试运行 10 次并计算平均值。
任务是——生成一个整数序列并对序列中的所有整数求和。这是 Python 代码
def foldl(func, start, seq):
if len(seq) == 0:
return start
return foldl(func, func(start, seq[0]), seq[1:])
foldl ((lambda x, y: x + y), 0, range(1, 5001))
这是 Ela 的解决方案
foldl f z (x::xs) = foldl f (f z x) xs
foldl _ z [] = z
_ = foldl (+) 0 [1..5000]
结果
Python | 0.249587998545 毫秒 |
---|---|
Ela | 0.0042914 毫秒 |
好的,这看起来令人惊讶。此外,为了在 Python 中运行此代码,我不得不显式增加允许的递归限制。如果我将集合大小设置为包含 10000 个元素,Python 解释器仍然会无故崩溃。我从官方 Python 文档中获取了 foldl
函数定义(参见函数式编程 HOWTO),所以它应该没问题,对吗?
好吧,坦率地说。函数 foldl
是一个尾递归函数,Ela 可以优化尾递归并消除不必要的函数调用,而 Python 显然不能。结果,比较不是很公平。让我们修改 Ela 代码
foldr f z (x::xs) = f x (foldr f z xs)
foldr _ z [] = z
_ = foldr (+) 0 [1..5000]
函数 foldr
从右到左处理列表,并且不是尾递归的。因此 Ela 将无法优化它。由于我们只是计算元素的总和,foldr
将与 foldl
完全相同,所以我甚至不打算更改 Python 代码。所以,第二次运行
Python | 0.253751705846 毫秒 |
---|---|
Ela | 0.0069557 毫秒 |
正如你所看到的,这并没有帮助 Python。Ela 仍然快得多。此外,即使是带有 foldr
的版本也能很好地处理一百万个元素的列表,而 Python 在一万个元素的集合上崩溃。
所以你可能会同意我的观点,如果你想编写函数式代码,Ela 不是最糟糕的编程语言。
我为什么需要它?
或者,当已经有大量的编程语言时,我们为什么还需要另一种编程语言?我们确实有很多编程语言,但 Ela 有其独特的特性。
首先,Ela 是一种动态函数式语言。这不是一个典型的组合,因为大多数现代函数式语言都是静态类型的。动态类型极大地简化了语言。如果你想学习函数式编程范式,Ela 在这里教你,通过 Ela,你将真正学习函数式编程,而不是特定类型系统的限制和特性。
此外,Ela 是少数具有动态类型的真正函数式语言之一。我知道你不会同意我的观点,因为有一些传统上受尊重的语言——对我们一些人来说——甚至代表了函数式编程范式。但让我们客观一点。你认为函数式语言可以没有不变性吗?或者函数式语言应该将命令式循环作为主要的控制结构吗?或者当你对非玩具示例使用函数式模式时意外崩溃?或者通过内置类型和其他支持的范式偏爱副作用?你能想象一个没有任何内置部分函数应用机制的函数式语言吗?好吧,你当然可以想象,因为有很多,但我们为什么要称它们为函数式呢?当函数式编程是关于组合函数时,如果语言缺乏任何内置工具,这不是一项容易的任务。
综上所述,我认为 Python 不是函数式语言。我认为 JavaScript 不是函数式语言。我怀疑——是的,开枪吧——Lisp 是函数式语言。是的,Lisp 是函数式编程的鼻祖,但它也是这种范式开始发展的语言。如果你从 ML 程序员的角度来看它,你很难找到任何真正的理由称之为函数式。一流函数?Python 也有。可变链表?嗯,我甚至不想评论。
Ela 遵循 ML\Haskell 的传统。当然,理解和定义函数式编程有很多种方式,但如果你更喜欢坚持 ML 传统,那么 Ela 是你的正确选择,因为它以同样的方式看待函数式范式。
但我还没有回答一个问题——你为什么需要它?嗯,可能有以下几个原因
- 学习函数式编程。Ela 确实是学习函数式范式的好选择。它比其他流行的函数式语言入门成本低得多,但会教你几乎相同的东西。在 Ela 之后,你肯定能够将你的知识重复利用到学习其他语言,如 OCaml、F#、Haskell 等。
- 嵌入。Ela 虚拟机没有通过静态变量维护内部状态,您可以在同一个进程和应用程序域中运行两个实例。它也是线程安全的,并且易于从 .NET 代码中使用。此外,它不需要任何安装,并且语言的实现包含在一个单独的 DLL 中。
- 原型设计。与许多动态语言一样,原型设计是 Ela 表现最佳的关键领域之一。在 Ela 中,您不必设计类型并向编译器证明程序的正确性。您只需实现您的任务。
- 潜在地,所有动态类型是福而不是祸的任务。例如 Web 应用程序、数据库客户端、管理脚本等。Ela 标准库目前有些受限——然而,您总是可以为 Ela 实现自己的模块,这是一项相当简单的任务。
- 数学和研究项目。Ela 为您提供了函数式世界的精华,同时又非常灵活和可扩展。借助 Ela 运行时环境,您可以例如在库级别实现对符号方程的支持。我甚至没有提到定理证明器之类的东西——这些都是函数式语言的用途。
- 领域特定语言。Ela 对扩展其语法提供的支持有限,但开箱即用的功能已经足够灵活和富有表现力,可用于您自己的 DSL。像往常一样,使用 Ela 构建 DSL 确实很容易。此外,Ela 是一种在其自己的虚拟机上运行的解释型语言——它与动态编译为机器代码的语言不同。使用 Ela,您的 DSL 将更加安全,并且可以在低信任环境中自由执行。
Ela 功能的一个很好的例子是标准库中的 espec 模块。该模块实现了用于测试规范的 DSL,允许您以接近自然语言的形式编写测试,并生成详细的测试结果,并对每个步骤进行跟踪。
这是 espec 中一个简单测试规范的示例
test1 =
test "Demonstration of espec"
given [1..5]
should contain 1
shouldn't contain 3
shouldn't contain 6
do reverse
should be [5,4..1]
do tail
should be [3,2..1]
do head
should be 4
这是测试输出Test session started.
Test "Demonstration of espec" (failed 2, errors 0):
given [1,2,3,4,5]
should contain 1 -> success
shouldn't contain 3 -> failed (got [1,2,3,4,5])
shouldn't contain 6 -> success
do reverse -> [5,4,3,2,1]
should be [5,4,3,2,1] -> success
do tail -> [4,3,2,1]
should be [3,2,1] -> failed (got [4,3,2,1])
do head -> 4
should be 4 -> success
Total tests:1 Failed: 1
得益于灵活的语法和动态类型,能够编写“即时”计算类型的函数(这在静态类型系统中是不可能实现的),Ela 允许您编写真正富有表现力和灵活的代码。这是“解开束缚的函数式编程”,没有笨拙的语法或静态类型的限制。
结论
Ela 仍在积极开发中——目前,我正在进行文档和标准库的工作。Ela 源代码在 GPL v2 许可证下免费提供,可在 Google Code 存储库中找到(参见下面的“链接”部分)。您还可以下载最新的二进制版本并阅读更详细描述语言功能的文档。我总是很高兴听到任何反馈和评论,如果您决定在您的应用程序中使用 Ela,我也会很乐意帮助您。