Clean语言快速体验
原文见(http://wiki.clean.cs.ru.nl/Quick_impression)
我们在这里展示一些Clean的非常基础的例子,给那些之前从没有见过函数式编程语言的人或只是为了比较Clean与别的函数式语言的人体验一下Clean代码长什么样。
尽管语言的实际能力在正式的应用中才能体现出来,不过这些很小的例子可以给你体验到紧致和优雅的语言结构。完整的语言描述可参考Clean文档。可以在这个文档中找到用Clean进行函数式编程的详细介绍。
这个概述是从Hilt网页复制而来的。随后会简短地讨论语言结构:像任何一个新语言一样,Clean包括一些你可能看着不太熟悉的特殊语法和语言结构。如果例子中的程序代码使你感到困惑时请花一点时间读一下解释。这个语言的特性与C、C++和Java的特性多少有些不同。如果初看之下不理解它的结构不要责怪Clean语言。在Clean语言中学习函数式编程更容易,它在函数式语言中的资格相当于程序员的C++或Java。
我们假设你熟悉编程的基本概念。
在像Clean这样的函数式语言中定义函数与数学中的函数定义非常相似。在许多编程语言中习惯性的很多语法碎屑都可避免。甚至括起参数的圆括号都可以省略。求一个数的平方(square)的函数可以这样定义:
square :: Int -> Int //函数的类型:从Int到Int
square n = n * n //值为参数与自身相乘
第一行给出这个函数的类型:一个作为参数的整数和一个作为结果的整数。第二行定义怎样把square函数应用到一个任意参数n上计算出结果:通过参数与自身相乘。
想快速选择的话,我们可以定义一个有几个候选函数体的函数。例如阶乘函数可定义为:
fac :: Int -> Int
fac 0 = 1
fac n = n * fac (n-1)
在这样的定义中,使用实际参数的第一个候选函数体将被(优先)使用。这个策略强制第一个候选函数体被应用到表达式fac o 上。在Clean语言中,每个程序计算表达式Start的值。通过为这个函数提供一个合适的定义,任意表达式的值能够被计算出来。六的阶乘通过这个程序计算出来:
Start :: Int
Start = fac 6
Start表达式的值被显示给用户。对于这个例子,程序会写720到控制台。因为这个表达式的值是显示给用户,著名的"Hello world"程序就可这样写:
Start :: String
Start = "Hello world"
当然有许多函数需要多于一个的参数。一个简单的例子是对于整数x和n,计算x的n次幂的函数:
power :: Int Int -> Int
power x 0 = 1
power x n = x * power x (n-1)
这个函数在编程语言中通常用中缀操作符^表示。一个中缀操作符实际只是一个被写在它的参数之间的普通函数:x^n。在Clean语言中,你可以定义自己的中缀操作符。中缀操作符的定义就像别的函数一样。但有两个细微的区别:在函数的类型声明中我们指出它是一个中缀操作符,在定义的左边,用一对圆括号把它括起来。如:
(^) infixr 8 :: Int Int -> Int
(^) x 0 = 1
(^) x n = x * x ^ (n-1)
在类型声明中的关键字infixr指出这个函数是一个右结合中缀(infix)操作符。数字8指出该操作符的结合力。它的结合力是按照数学习惯进行选择的。这就意味着在这个操作符的第二个候选函数体中不需要使用圆括号来区分(x*x)^(n-1)和x*(x^(n-1))。实际上,在clean的标准环境中这个操作符被定义为一个类型类。
尽管通过模式区分函数定义的各种情况是非常强有力的,但还不够。例如,使用模式不可能检测一个整数参数是正的或大于另一个参数。这可以使用守卫来解决。守卫是一个可以插入在候选函数模式和"="间的一个布尔表达式。符号“|”分离模式和守卫。当守卫得出True时,就只应用该候选项。每个候选函数可以有一连串的守卫在右边。考虑下面的maximum函数,它得出两个参数中的最大者:
maximum :: Int Int -> Int
maximum n m
| n < m = m
| n >= m = n
当你知道在这样的候选函数中的最后守卫总是产生True,如上面这个例子,它可以被省略或用关键字otherwise代替。
函数可以是另一个函数的参数和结果。作为一个有点极端的例子,我们考虑twice函数。twice函数取一个函数f和一个参数x作参数。函数f被应用到参数x两次:
twice f x = f (f x)
使用这个函数,我们可以计算2的平方的平方:
Start = twice square 2
这个程序得到16。用相同的风格我们可以通过下面的程序计算square (square (square (square 2)))的值:
Start = twice twice square 2
这个程序得出65536。
就像多态和类型类,高阶函数让程序更容易理解、修改和重用。
有许多函数,它们的参数类型是无关紧要的。最简单的例子是恒等函数( identity function)I。这个函数的参数就是它的结果。这个参数的类型是无关紧要的。在多态函数的类型定义的类型部分使用一个类型变量就可以达到目的(有多种形式)。如:
I :: t -> t //类型为从类型t到类型t
I x = x
当然上面定义的twice函数也是多态的。在我们的例子中我们用一个Int型x和一个Int -> Int类型的函数作参数来使用它。实际上,上面定义的square函数的类型加上一些限制是必要的。这个函数可用于任何提供了乘法的类型t。可以通过“| * t”这种形式的类型约束来表明这一点。下面的square函数使用了更通用的类型:
square :: t -> t | * t
square n = n * n
上面定义的maximum函数也可以有更通用的类型:
maximum :: t t -> t | < t
maximum n m
| n < m = m
| n >= m = n
maximum函数的类型表明它可以用于有相同类型t的任意两个元素,只要提供的这种类型可以被比较。
当定义了小于<后,Clean系统知道怎样计算大于或等于。函数可以处理各种类型的参数的形式叫多态。当对一个或多个类型变量存在类型约束如 * t时,我们有一个隐式的类型类而不是一个多态函数。
列表是Clean中的一个基本的复合数据结构。一个列表可以有任意数目的元素。一个列表的所有元素应该有相同的类型。因为在函数式程序语言中列表是如此普遍,所以列表有一些特殊语法。一个整数列表可以被写成一个枚举:[7,12]或[1,2,3,4,5]。也可以在表达式中使用列表产生式如[1..5]。这样的表达式组叫做点点表达式(dot dot expression)。空列表写作[]。最后,一个带有头元素x和尾元素xs的列表写作[x:xs](注意[x,xs]和[x:xs]的区别,前者是一个有两个元素的列表)。
这个列表记法可以用在函数的模式匹配中。例如,函数product计算一个列表的元素的积。它的类型声明说明这个函数取一个任意类型t的列表作参数并产生一个类型t的值,其中类型t是类型类one和*的成员。这就是说类型t中要定义乘法和它的单位元素one。
product :: [t] -> t | one, * t
product [] = one
product [x:r] = x * product r
这个函数的第一个候选项说明一个空列表的积是one。第二个候选说明一个由首元素x和尾元素r组成的列表的积等于列表的首元素与列表尾部的积相乘的结果。编译器为每个应用选择恰当的类one和*的实例。
这可以用作给出阶乘函数的一个候选定义。某数n的阶乘等于从1到n的数组成的列表的积:
fac :: Int -> Int
fac n = product [1..n]
相同的函数product可以用到许多别的类型,例如计算一个real的列表的积。
在函数式编程语言Clean中,列表推导式是非常简洁和强大的快速操作列表的方法。例如要计算1到10的所有整数的平方不能被3整除的数,可用下面的程序:
Start = [n*n \\ n <- [1..10] | n rem 3 <> 0]
正如预期的这个程序产生[1,4,25,49,64,100]。一般而言,一个列表推导式产生的列表值在[和\\中指定。这些值是计算发生器(generator,在\\和|中的部分)中每个元素得到的,其中计算过程要按照|和]间的条件来进行。下面是一个更现实的程序,我们定义一个元组的列表作为里程表:
:: From :== String
:: To :== String
:: Km :== String
table :: [(From,To,Km)]
table = [ ("Amsterdam", "Nijmegen",122 )
, ("Paris" , "Amsterdam" ,490 )
, ("Paris" , "Rome" ,1140)
, ("Berlin" , "Amsterdam" ,705 )
, ("Amsterdam", "Kobenhaven",764 )
, ("Amsterdam", "Rome" ,1640)
, ("Moscow" , "Amsterdam" ,2523)
]
我们可以使用列表推导式选择所有与Amsterdam距离小于1000km的城市:
Start =
[ (to, dist)
\\ ("Amsterdam", to, dist) <- table
| dist < 1000
]
注意在这个列表推导式的发生器中我们已经用了模式 ("Amsterdam",to,dist) 。匹配这个模式的列表唯一元素对列表推导式的结果产生了影响。
事实上你不会满意这个程序。通常我们会从两个方向读一个里程表。从Paris到Amsterdam是490 Km通常意味着Amsterdam和Paris的距离也是490 Km。不过元组 ("Paris","Amsterdam",490)不匹配该模式。我们可以考虑颠倒table中From和to的城市来解决这个问题。
Start =
[ (to, dist)
\\ ("Amsterdam", to, dist) <- table ++ [(t2, t1, d) \\ (t1, t2, d) <- table]
| dist < 1000
]
这些列表都只有一个发生器--计算列表元素值的来源。通常程序中可以有多个发生器。我们可以计算从Amsterdam通过某些别的城市到达的城市。
Start =
[ (to, dist1 + dist2)
\\ ("Amsterdam", t2, dist1) <- connections
, (t3 , to, dist2) <- connections
| t2 == t3
&& to <> "Amsterdam"
]
where connections = table ++ [(t2, t1, d) \\ (t1, t2, d) <- table]
这里我们已经使用了一个局部定义,可以认为connections的定义是共享的。局部定义的作用域是它们被定义的候选函数体。局部定义用于限定定义的作用域,共享一个值或命名一个表达式。在本文中局部定义用在关键字where之后。Clean语言中有丰富的局部定义“调色盘”。
最后,列表推导式中的条件不包括在Amsterdam结束的路线。在这个例子中发生器被分号(译者:代码中是逗号)隔开,这意味着所有从发生器中得到的元素被合并。也可以用&符号分隔发生器,这意味着发生器被同时使用。下面这个例子用连续数标注从Amsterdam可到达的城市。
Start =
[ (n, to, dist)
\\ (to, dist) <- fromAmsterdam
& n <- [1..]
]
where fromAmsterdam =
[ (to, dist)
\\ ("Amsterdam", to, dist) <- table ++ [(t2, t1, d) \\ (t1, t2, d) <- table]
]
因为我们不知道从Amsterdam可到达的城市的数目的上限,我们在“点点”表达式[1..]中省略了上限。这个点点表达式表示一个无穷的整数列表。感谢Clean的惰性计算(译注:又译延迟计算)策略,我们可以处理这样的潜在无穷数据结构。惰性计算是指直到需要产生结果时才计算表达式的值。
我们将会用下面的一些附加例子演示列表推导式的使用。
Clean有一个与关系数据库系统和许多现代编程语言类似的记录的概念。一个简单例子是记录类型Product。这个类型的记录包含三个字段:一个String类型的产品名,一个Real类型的价格,一个String类型的供货商:
:: Product = { name :: String
,price :: Real
,supplier :: String
}
该类型的一个具体结构可以通过指定所有字段的值来定义。例如:
beer :: Product
beer = { name = "Grolsch Beer"
,price = 0.80
,supplier = "Groenlo Brewery"
}
当然在记录的字段中你可以使用任何你想用的类型。编译器通常会检查在你的程序中出现的任何记录的字段的类型一致性。一个使用复合类型的记录的简单例子是Order(定单)。记录类型Order的contents(内容)字段是一个叫Item的记录类型的列表。在数据库术语中,这叫非第一范式(non-first normal form,NFNF)。
Order = { customer :: String
, date :: Date
, contents :: [Item]
}
:: Item = { product :: String
, quantity :: Int
}
以下是使用这个类型的一个小例子:
myOrder :: Order
myOrder = { customer = "Pieter"
, date = "30 jan '98"
, contents = [ { product = "Grolsch Beer"
, quantity = 5
}
, { product = "Pizza"
, quantity = 1
}
]
}
列表推导式来处理这些记录的列表非常有用。下面通过total函数来演示。这个函数取一个定单(order)和一个产品(product)的列表作参数,计算定单项目的总价。
total :: Order [Product] -> Real
total order products =
sum [ toReal i.quantity * p.price
\\ i <- order.contents
, p <- products
| p.name == i.product
]
这与SQL查询很像:
SELECT sum (i.quantity * p.price)
FROM myOrder.contents i, Product p
WHERE p.name = i.product
与在宿主语言中内嵌SQL相比,列表推导式与Clean语言完全整合在一起。本文中的例子显示列表推导式可以成为递归函数的一部分。列表推导式的结果是一个普通的列表。这样的列表可以被任意的列表处理函数进行处理。这就是说,它不会被限制在SQL的有名的五种聚合函数中。
另一个要了解的重要方面是Clean不是一个数据库系统。它没有事务管理的基元,也不能高效地处理离奇大的列表。
Clean有一个非常强大的类的概念。这个“类”与你知道的来自面向对象语言中的“类”有很多不同,了解这一点对理解Clean中类的概念很重要。在Clean中,一个类是带有相同名称的一个函数家族。这些族成员的不同是类型的处理。作为一个非常简单的例子考虑下面的增加函数类:
class inc t :: t -> t
这是说在类inc中有类型变量t。在这个类中仅有一个操作函数,它也叫做inc。这个增加函数的类型是t->t。这个类对于整数和实数的实例定义为:
instance inc Int where
inc i = i+1
instance inc Real where
inc r = r+1.0
甚至记录Item、一个order的元素都可以被增加:
instance inc Item where
inc i = {i & quantity = inc i.quantity}
这样读item i的增加,item和它的字段quantity设置为来自参数记录中该字段增量。
基本操作符如+、==和<都被定义为类型类,事实上我们已经用它来定义了上面显示的product和square函数。这就是说我们自己的数据类型也可以定义这些基本操作符。例如我们可以定义一个产品(从上面的记录Product)的比较函数来比较它们的名称:
instance < Product where
(<) p1 p2 = p1.name < p2.name
使用列表推导式我们可以很容易地为类<的元素的列表定义有名的快速排序算法。快速排序算法说明空列表直接存储。一个非空列表被分割成小于首元素的部分和大于或等于首元素的部分,这些子列表被分别排序,结果列表被添加操作符++粘合到一起:
qsort :: [t] -> [t] | < t
qsort [] = []
qsort [e:r] = qsort [ x \\ x <- r | x
++ [e] ++
qsort [ x \\ x <- r | x>=e ]
操作符>=衍生自<:
class >= a where
(>=) infix 4 :: a a -> Bool | < a
(>=) x y :== not (x
这就是说一旦定义了操作符<就可以定义>=。qsort函数可以像对整数列表排序一样对一个产品列表进行排序和对任何别的类<的成员进行排序。要成为相同类的成员,数据类型没有任何关系。只要那个数据类型定义了恰当的函数就足够了。正如你现在理解的,Clean中的类比大多数面向对象语言中的类更通用。使用Clean的类的概念很容易模仿别的面向对象语言中的类的概念。
实际上你每次用类型约束定义一个多态函数时你就定义了一个类。考虑一个把增值税(VAT)加到整数类型的价格上的函数。这个函数转换它的参数到一个实数,用VAT和price进行计算,再把实数转换回原始类型。
addVAT :: t -> t | toReal, fromReal t
addVAT p = fromReal ((1.0 + VAT) * toReal p)
VAT :== 0.175
确切的类型转换用函数进行确定。在下面的程序中我们用这个函数把VAT加到一个整数和一个实数。
Start = (addVAT 100, addVAT 100.0)
这个程序得到(118, 117.5)。
一个代数数据类型的最简单版本是仅列举出可能的值。例如代数数据类型Day包含七个不同的构造子:
:: Day = Sun | Mon | Tue | Wed | Tur | Fri | Sat
像上面这样通过数字或字符串表示星期几的代数数据类型的好处是编译器可以保证在程序运行期间只有一个有效值出现。(译注:这里::相当于haskell中的关键字data)
代数数据类型比单独的枚举类型更通用。代数数据类型的构造子可以带有一定数量的参数。这些参数可以是任何类型,包括构造子本身。因此,代数数据类型可递归。通过提供一个或多个类型变量作参数给代数数据类型,这个类型就成为多态的。一个明显的多态代数数据类型的例子是列表类型:
:: List t = Nil | Cons t (List t)
因为在函数式语言中使用这种预定义的列表类型显得很笨拙,所以有一些对列表的特殊语法和语言结构(像列表推导式)。另一个例子是树。
Tree表示任意类型的树。一棵树要么是空的,要么有一个包含Tree a类型的左子树、一个a类型的值和一个右子树的节点。
:: Tree a = Empty | Node (Tree a) a (Tree a)
因为Tree是多态的,任何元素类型都可以存储在一棵树中。不过,Clean类型系统保证每个树的实例只包含一种给定类型的元素。例如一棵包含整数列表的树是这样:
a_tree :: Tree [Int]
a_tree = Node (Node Empty [] Empty) [1..3] Empty
这棵树的顶节点包含带有整数1、2、3的列表,它的左子树仅包含一个带有空整数列表的节点,它的右节点的顶节点为空。
Trees可以被普通函数操作。通常它方便地用不同的候选函数对不同的构造子进行操作,用模式匹配选择一个构造子的参数。例如翻转一棵树的函数被定义为:
Flip :: (Tree a) -> Tree a
Flip Empty = Empty
Flip (Node left elem right) = Node (Flip right) elem (Flip left)
翻转一棵空树的结果是空树。翻转一棵带有左子树left、元素elem和右子树right的节点组成的树是另一个节点。新节点的左子树通过翻转子树right得到,新节点的元素是原始的元素,新节点的右子树是原来的左子树的翻转版本。
下面定义一个通过前序遍历树(从左到右,深度优先)转换一棵树到一个列表的简单函数。
toList :: (Tree t) -> [t]
toList Empty = []
toList (Node l elem r) = toList l ++ [elem] ++ toList r
为了转换列表到有序的树、搜索列表,我们首先定义一个插入一个单一元素到一棵有序树的函数。当然它要求树的元素可以进行比较。因此,我们有语言一环境“< a”。
insertTree :: a (Tree a) -> Tree a | < a
insertTree x Empty = Node Empty x Empty
insertTree x (Node l y r)
| x < y = Node (insertTree x l) y r
| otherwise = Node l y (insertTree x r)
一个列表的元素可以通过函数toTree转换到一棵有序树。一个空列表变成一棵空树。一个带有头hd和尾tl的列表被转换成一棵树,这棵树通过把头hd插入到转换尾tl成的树中形成。
toTree :: [a] -> Tree a | < a
toTree [] = Empty
toTree [hd:tl] = insertTree hd (toTree tl)
对树排序的函数是函数toList和toTree的复合函数(通过中缀操作符o表明)。复合函数与数学中的定义相同:(f o g) x = f (g x)。
tsort :: ([a] -> [a]) | < a
tsort = toList o toTree
这样的多态代数数据类型的能力可与像C++这样的语言中的模板譬美。但是,代数数据类型 在函数式编程语言更容易理解和使用(没有指针处理和悬挂指针问题),更通用和完全的类型安全。
使用一些玩具程序我们给你带来了一个在Clean语言中函数式编程的快速体验。尽管简洁和紧致的记法你看起来有点不太熟悉,但它远不止是一个有趣的玩具。精炼的语法使你更清楚地观察在函数中实际发生的事。
你可能注意到了不存在副作用。引用透明性是语言的一个普通属性,引用透明性是表达式的值仅依赖于函数和它的参数的术语形式。这就可以通过等式推理来程序。
Clean的静态类型系统保证运行时类型错误不会发生。也就排除了程序对这种错误进行测试的需要。通过编译器验证类型一致性后程序更快、更安全。你可以花更多时间来验证和改进程序行为。
0赞