我们将继续介绍有关F#中的函数式编程的系列文章。 今天,我们有一个非常有趣的话题:函数的定义。 其中,让我们讨论匿名函数,不带参数的函数,递归函数,组合器等等。 看猫下!

功能定义
我们已经知道如何使用“ let”语法创建常规函数:
let add xy = x + y
在本文中,我们将介绍创建函数的其他方法,以及定义函数的技巧。
匿名函数(lambdas)
如果您熟悉其他语言的lambda,则以下几段看起来很熟悉。 匿名函数(或“ lambda表达式”)的定义如下:
fun parameter1 parameter2 etc -> expression
与C#中的lambda相比,有两个区别:
- lambdas应该以
fun
关键字开头,这在C#中不是必需的 - 使用单箭头
->
,而不是C#中的double =>
。
Lambda加法函数的定义:
let add = fun xy -> x + y
传统形式的功能相同:
let add xy = x + y
Lambda通常以小表达式的形式使用,或者在不希望为表达式定义单独的函数时使用。 如您所见,在使用列表时,这并不罕见。
// let add1 i = i + 1 [1..10] |> List.map add1 // [1..10] |> List.map (fun i -> i + 1)
请注意,必须在lambda周围使用括号。
当需要明显不同的功能时,也可以使用Lambda。 例如,可以使用lambdas重写前面讨论的“ adderGenerator
”(我们之前讨论过) 。
// let adderGenerator x = (+) x // let adderGenerator x = fun y -> x + y
lambda版本稍长一些,但是立即使您很清楚将返回一个中间函数。
Lambda可以嵌套。 adderGenerator
定义的另一个示例,这次仅在lambda上。
let adderGenerator = fun x -> (fun y -> x + y)
您是否清楚所有三个定义都相同?
let adderGenerator1 xy = x + y let adderGenerator2 x = fun y -> x + y let adderGenerator3 = fun x -> (fun y -> x + y)
如果不是,请重新阅读有关curring的章节 。 这对于理解非常重要!
模式匹配
定义函数后,可以像上面的示例中那样显式地将参数传递给它,但是也可以直接在参数部分与模板进行比较。 换句话说,参数部分可能包含模式(匹配模式),而不仅仅是标识符!
以下示例演示了在函数定义中使用模式:
type Name = {first:string; last:string} // let bob = {first="bob"; last="smith"} // // let f1 name = // let {first=f; last=l} = name // printfn "first=%s; last=%s" fl // let f2 {first=f; last=l} = // printfn "first=%s; last=%s" fl // f1 bob f2 bob
仅当对应关系始终可确定时,才可以进行这种类型的比较。 例如,您不能以这种方式匹配联合类型和列表,因为某些情况无法匹配。
let f3 (x::xs) = // printfn "first element is=%A" x
编译器将发出有关不完全匹配的警告(空白列表将在此函数的入口处导致运行时错误)。
常见错误:元组与 许多参数
如果您来自类似C的语言,则用作该函数唯一参数的元组可能会非常类似于多参数函数。 但这不是同一回事! 如前所述,如果您看到逗号,则很可能是元组。 参数以空格分隔。
混淆示例:
// let addTwoParams xy = x + y // - let addTuple aTuple = let (x,y) = aTuple x + y // // let addConfusingTuple (x,y) = x + y
- 第一个定义“
addTwoParams
”采用两个参数,以空格分隔。 - 第二个定义“
addTuple
”采用一个参数。 此参数将元组中的“ x”和“ y”绑定并求和。 - 第三个定义“
addConfusingTuple
”采用单个参数,例如“ addTuple
”,但是窍门是将这个元组解包(与模式匹配)并使用模式匹配将其绑定为参数定义的一部分。 在幕后,所有操作都与addTuple
完全相同。
让我们看一下签名(如果不确定某些内容,请始终查看它们)。
val addTwoParams : int -> int -> int // val addTuple : int * int -> int // tuple->int val addConfusingTuple : int * int -> int // tuple->int
现在在这里:
// addTwoParams 1 2 // ok -- addTwoParams (1,2) // error - // => error FS0001: This expression was expected to have type // int but here has type 'a * 'b
在这里,我们看到了第二个调用中的错误。
首先,编译器将(1,2)
视为形式('a * 'b)
的通用元组,尝试将其作为第一个参数传递给addTwoParams
。 之后,他抱怨期望的第一个参数addTwoParams
不是int
,但是尝试传递一个元组。
要制作元组,请使用逗号!
addTuple (1,2) // ok addConfusingTuple (1,2) // ok let x = (1,2) addTuple x // ok let y = 1,2 // , // ! addTuple y // ok addConfusingTuple y // ok
反之亦然,如果您将多个参数传递给等待元组的函数,您还将收到一个无法理解的错误。
addConfusingTuple 1 2 // error -- // => error FS0003: This value is not a function and // cannot be applied
这次,编译器决定一旦addConfusingTuple
两个参数, addConfusingTuple
应该对addConfusingTuple
进行处理。 条目“ addConfusingTuple 1
”是部分应用程序,应该返回一个中间函数。 尝试使用参数“ 2”调用此中间函数将引发错误,因为 没有中间功能! 我们看到了与curring一章中相同的错误,该章讨论了参数过多的问题。
为什么不使用元组作为参数?
上面对元组的讨论显示了定义具有许多参数的函数的另一种方法:可以将所有参数组装成一个结构,而不必分别传递它们。 在下面的示例中,该函数采用单个参数-三个元素的元组。
let f (x,y,z) = x + y * z // - int * int * int -> int // f (1,2,3)
应该注意的是,签名不同于具有三个参数的函数的签名。 只有一个箭头,一个参数和指向元组(int*int*int)
星号。
什么时候需要提交带有单独参数的参数,以及何时使用元组?
- 当元组本身很重要时。 例如,对于在三维空间中进行操作,三元组将比分别使用三个坐标更方便。
- 有时,元组用于将必须存储在一起的数据合并到一个结构中。 例如,.NET库中的
TryParse
方法返回结果和一个布尔变量作为元组。 但是要存储大量相关数据,最好定义一个类或记录( record) 。
特殊情况:.NET库元组和函数
调用.NET库时,逗号非常常见!
它们都接受元组,并且调用看起来与C#中的相同:
// System.String.Compare("a","b") // System.String.Compare "a" "b"
原因是经典.NET的功能无法使用,无法部分应用。 所有参数必须始终立即传输,最明显的方法是使用元组。
请注意,这些调用仅看起来像传输元组,但这实际上是一种特殊情况。 您不能将真正的元组传递给以下函数:
let tuple = ("a","b") System.String.Compare tuple // error System.String.Compare "a","b" // error
如果要部分应用.NET函数,则可以像之前所做的那样或如下所示在它们上面编写包装器:
// let strCompare xy = System.String.Compare(x,y) // let strCompareWithB = strCompare "B" // ["A";"B";"C"] |> List.map strCompareWithB
选择单个参数和分组参数的指南
元组的讨论引出了一个更笼统的话题:何时应该分开参数,何时将参数分组?
在这方面,您应注意F#与C#的区别。 在C#中, 总是传递所有参数,因此,这里根本不会出现这个问题! 在F#中,由于部分应用,只能表示某些参数,因此有必要区分参数应组合的情况和参数独立的情况。
有关在设计自己的功能时如何构造参数的一般建议。
- 在一般情况下,最好使用单独的参数而不是传递一个结构(元组或记录)总是更好。 这允许更灵活的行为,例如部分应用程序。
- 但是,当需要一次传递一组参数时,应使用某种分组机制。
换句话说,在开发功能时,问自己:“我可以单独提供此参数吗?” 如果答案为否,则应将参数分组。
让我们看几个例子:
// . // , let add xy = x + y // // , let locateOnMap (xCoord,yCoord) = // // // - type CustomerName = {First:string; Last:string} let setCustomerName aCustomerName = // let setCustomerName first last = // // // // , let setCustomerName myCredentials aName = //
最后,确保参数的顺序对部分应用有所帮助(请参阅此处的手册)。 例如,为什么在最后一个函数aName
放在myCredentials
之前?
没有参数的功能
有时您可能需要一个不接受任何参数的函数。 例如,您需要可以多次调用的函数“ hello world”。 如上一节所示,天真定义不起作用。
let sayHello = printfn "Hello World!" //
但这可以通过在函数中添加单位参数或使用lambda来解决。
let sayHello() = printfn "Hello World!" // let sayHello = fun () -> printfn "Hello World!" //
之后,应始终使用unit
参数调用该函数:
// sayHello()
与.NET库进行交互时经常发生的事情:
Console.ReadLine() System.Environment.GetCommandLineArgs() System.IO.Directory.GetCurrentDirectory()
请记住,使用unit
参数调用它们!
定义新的运营商
您可以使用一个或多个运算符来定义函数(有关字符列表,请参阅文档 ):
// let (.*%) xy = x + y + 1
您必须在字符周围使用括号来定义函数。
以*
开头的运算符需要在括号和*
之间加一个空格,因为 在F#中(*
用作注释的开头(如C#中的/*...*/
):
let ( *+* ) xy = x + y + 1
定义后,如果将新功能括在方括号中,则可以按常规方式使用它:
let result = (.*%) 2 3
如果该函数与两个参数一起使用,则可以使用不带括号的中缀运算符记录。
let result = 2 .*% 3
您还可以定义以!
开头的前缀运算符!
或~
(有一些限制,请参见文档 )
let (~%%) (s:string) = s.ToCharArray() // let result = %% "hello"
在F#中,定义语句是一个相当常见的操作,许多库将导出名称,如>=>
和<*>
。
无点式
我们已经看到了许多缺少最新参数以减少混乱程度的函数示例。 这种样式称为无点样式或默认编程 。
以下是一些示例:
let add xy = x + y // let add x = (+) x // point free let add1Times2 x = (x + 1) * 2 // let add1Times2 = (+) 1 >> (*) 2 // point free let sum list = List.reduce (fun sum e -> sum+e) list // let sum = List.reduce (+) // point free
这种风格各有利弊。
优点之一是,重点在于高阶函数的组成,而不是对低级对象大惊小怪。 例如,“ (+) 1 >> (*) 2
”是显式加法,后跟乘法。 并且“ List.reduce (+)
”清楚地表明,无论列表信息如何,加法操作都很重要。
无意义的样式使您可以专注于基本算法并识别代码中的常见功能。 上面使用的“ reduce
”功能就是一个很好的例子。 该主题将在计划的列表处理系列中进行讨论。
另一方面,过多使用这种样式会使代码晦涩难懂。 显式参数充当文档,其名称(例如“列表”)使您更容易理解函数的功能。
像编程中的所有内容一样,最好的建议是偏向提供最清晰的方法。
组合器
“ 组合器 ”称为函数,其结果仅取决于其参数。 这意味着不依赖外部世界,尤其是没有其他功能或全局值可以影响它们。
实际上,这意味着组合功能以各种方式受到其参数组合的限制。
我们已经看到了几种组合器:管道和组合运算符。 如果查看它们的定义,那么很显然,他们所做的只是以各种方式对参数重新排序。
let (|>) xf = fx // pipe let (<|) fx = fx // pipe let (>>) fgx = g (fx) // let (<<) gfx = g (fx) //
另一方面,“ printf”之类的函数尽管是原始函数,但它们并不是组合器,因为它们依赖于外部世界(I / O)。
组合鸟
组合器是整个逻辑部分(自然称为“组合逻辑”)的基础,该部分在计算机和编程语言问世之前就已经发明了很多年。 组合逻辑对函数式编程有很大的影响。
要了解有关组合器和组合逻辑的更多信息,我推荐Raymond Smullyan的书“模拟一只知更鸟”。 在其中,他解释了其他组合器,并幻想给它们起鸟名 。 以下是一些标准组合器及其鸟名的示例:
let I x = x // , Idiot bird let K xy = x // the Kestrel let M x = x >> x // the Mockingbird let T xy = yx // the Thrush ( !) let Q xyz = y (xz) // the Queer bird ( !) let S xyz = xz (yz) // The Starling // ... let rec Y fx = f (Y f) x // Y-, Sage bird
字母名称是非常标准的,因此您可以将K-combinator引用给熟悉此术语的任何人。
事实证明,可以通过这些标准组合器来表示许多常见的编程模式。 例如,Kestrel是流畅接口中的常规模式,您可以在其中执行某些操作,但返回原始对象。 画眉是管道,Queer是直接合成,Y组合器在创建递归函数方面做得很好。
实际上,有一个众所周知的定理 ,即仅使用两个基本组合器(Kestrel和Starling)就可以构造任何可计算函数。
组合图书馆
组合库是导出许多旨在共享的组合功能的库。 这样的库的用户可以轻松地将功能组合在一起,以轻松获得更大,更复杂的功能,例如多维数据集。
设计良好的组合器库使您可以专注于高级功能,并隐藏低级“噪声”。 我们已经在“为什么使用F#”系列的几个示例中看到了它们的功能,并且List
模块中充斥着此类功能,如果您考虑一下,“ fold
”和“ map
”也是组合器。
组合器的另一个优点是它们是最安全的功能类型。 因为 它们不依赖外部世界;当全球环境变化时,它们也不会变化。 如果上下文发生更改,则读取全局值或使用库函数的函数可能会在调用之间中断或更改。 组合器永远不会发生这种情况。
在F#中,可使用组合器库进行解析(FParsec),创建HTML,测试框架等。 在下一个系列的后面,我们将讨论和使用组合器。
递归函数
函数通常需要从其主体引用自身。 一个经典的例子是斐波那契函数。
let fib i = match i with | 1 -> 1 | 2 -> 1 | n -> fib(n-1) + fib(n-2)
不幸的是,该函数将无法编译:
error FS0039: The value or constructor 'fib' is not defined
您必须使用rec
关键字告诉编译器这是递归函数。
let rec fib i = match i with | 1 -> 1 | 2 -> 1 | n -> fib(n-1) + fib(n-2)
递归函数和数据结构在函数式编程中非常常见,我希望以后再专门讨论这个主题。
其他资源
F#的教程很多,包括那些具有C#或Java经验的人的材料。 当您深入了解F#时,以下链接可能会很有用:
还介绍了其他几种开始学习F#的方法 。
最后,F#社区非常适合初学者。 在Slack上,由F#Software Foundation支持的聊天非常活跃,您可以自由加入初学者室。 我们强烈建议您这样做!
不要忘记访问俄语社区F#的网站 ! 如果您对学习语言有任何疑问,我们将很乐意在聊天室中讨论这些问题:
关于翻译作者
由@kleidemos翻译
在F#开发人员的俄语社区的努力下进行了翻译和编辑更改。 我们也感谢@schvepsss和@shwars为本文准备发表。