F#奇妙游(11):函数+值=程序

Break return但是函数

今天看到一篇讲F#计算表达式的文章F#计算表达式Head First:循环中实现Break return (一),文章把自定义计算表达式结构写得很清楚,文笔清新。

但是这个在循环中实现Break return的概念一下击中了我已经完全函数式编程化(就是最近的事情!)的大脑。

这很不函数!

我在F#奇妙游(6):如何不用for循环以及循环中专门讲到,典型的函数式编程思想,会第一时间考虑不采用循环。因为:循环的本质是遍历一个集合。而集合,是一个值。我们应该在函数式编程中描述要对这个值做什么,而不是描述怎么做。

Break return是典型的描述怎么做,从第一个元素开始循环,一个一个元素的查看,如果符合条件,返回该对象,退出循环,否则继续循环。而在函数式编程语言中,则应该描述:找到集合中第一个满足条件的对象。

let arr = [|1..100|]

let iString = 
    arr
    |> Array.find (fun i -> i > 50)
    |> fun i -> i.ToString()

sprintf "%s" iString

秉持同样的思路,还有找到符合条件的前三个元素,变成字符串数组。

let t = 
    arr
    |> Seq.ofArray
    |> Seq.filter (fun i -> i > 50)
    |> Seq.take 3
    |> Seq.map (fun i -> i.ToString())
    |> Array.ofSeq

sprintf "%A" t
[|"51"; "52"; "53"|]

函数还是循环

总结来看,F#提供的集合类型在。NET的基础上进行了适应函数式编程思想的扩展,特别是提供了那些高阶方法,例如上面的filter、map,还有iter就跟for循环是一样的。至于对break return这样的语义,当然是通过find、findIndex来实现。总之,采用函数式编程的思维,就应该把值的概念进行扩展,单块内存(值变量)是值,集合变量是值,函数、函数的函数、对象的成员函数都是值。

一旦能接受这个观点,那么在程序设计中,就会更好的在抽象的层次编程,典型的就是针对更加抽象的值进行“运算”。对我这样是C/C++背景的而言,这一点是函数式编程显得很难的主要原因。需要进行大量的思维训练,才能建立这种思维模式,并熟练地操作各种抽象的值。

至于在最终的实现层面,可能集合中第一个满足某个条件的元素就完全等同于,从0开始循环增加一个变量,用这个变量来索引集合,检测元素是否满足条件,如果满足则提前中断循环返回该元素,如果没有找到,则抛出异常。

实际上,FSharp.Core的源代码如下,正是通过尾递归实现的循环。

    [<CompiledName("Find")>]
    let find predicate (array: _[]) =
        checkNonNull "array" array

        let rec loop i =
            if i >= array.Length then
                indexNotFound ()
            else if predicate array.[i] then
                array.[i]
            else
                loop (i + 1)

        loop 0

其中的indexNotFound为:

    let inline indexNotFound () =
        raise (KeyNotFoundException(SR.GetString(SR.keyNotFoundAlt)))

考虑这些实际的复杂情况,最终这个需求的程序大概就像下面的代码。

let first pred arr =
    try
        Some (Array.find pred arr)
    with
        | :? System.Collections.Generic.KeyNotFoundException -> None
    
let arr = [|1;2;3;4|]

let v = first (fun x -> x < -1) arr

match v with
| None -> printfn $"Not found in %A{arr}" // do something when not found
| Some x -> printfn $"%A{x}"              // use the value


Not found in [|1; 2; 3; 4|]

三个范式

从这个代码,就发现函数式编程欲我以前的学习和编程习惯有很大的不同。函数式编程习惯从小的代码片段和切实概念出发(是一个实际的操作),逐步实现,层层组合,跟拼积木非常类似。跟面向对象相比,宏观的设计从微观的概念组合而得,并且这种概念与面向对象有很大不同就是习惯抽象动词。

编程范式抽象对象
面向过程计算机的运算步骤
面向对象名词、实体
函数编程动词

这个区别非常让我着迷。人类的大脑始终在努力地抽象、抽象,就比如视觉,我们的神经网络会自动把感光细胞的信号抽象为明、暗、色块这些概念,又以极高地效率抽象为我们能够触摸的对象,手机、电脑、鼠标,我们这一代人类,除了这类实体对象,还擅长一瞬间把电脑屏幕抽象为微博撕逼、知乎装逼、哔哩哔哩看……

抽象动作,这就是函数式编程的核心概念!写到这里,我感觉非常兴奋。动作和值!

但这就不得不让人思考,为什么?这有什么意思?这有什么好处?把上面的比较表格增加一列。

编程范式抽象对象特性
面向过程计算机的运算步骤数据+算法=程序
面向对象名词、实体对象、对象的状态
函数编程动词值经过动作产生新的值

虽然,从上面可以看到,不同的编程范式,归根结底都会成为处理器的指令、寄存器/内存中的编码,区别在于人对于业务的思维方式,在于看待对象(业务)的视角。

所以,diss某个语言或者某个编程范式,踩来踩去强调优点什么的都是没有理性的行为。像我们工程师,绝对不会因为工具丑就不使用。所有的工具都有其特殊的目的,能够达到目的就是好的工具。

  • 那么代价呢,狗蛋?
  • Everything!

一点点性能考虑

最后少关于Seq、Array、List的一点小小的探索。关于这三种集合的各种运算的复杂度,MSDN的文档中都有很好的描述。

测试工具

首先是时间的测试工具,System.Diagnostics.Stopwatch实际上的精度还是挺高的,1e7的频率,每个tick相当于是0.1微秒。这里把tick换算成浮点表示的毫秒时间。

定义一个time函数,测试一段unit->T的函数,然后返回值直接抛弃。最后利用time函数,定义一个重复运行N次,取平均值的

open System.Diagnostics

let msPerTick = 1.0e3 / (float Stopwatch.Frequency);

let ms (s:Stopwatch) =
    float s.ElapsedTicks * msPerTick
    
    
let time<'T> (f: unit->'T)=
    let s = Stopwatch()
    s.Start()
    f() |> ignore
    s.Stop()
    ms s
      
let timeN<'T> n (f:unit->'T)=
    [| 1..n |]
    |> Array.map (fun _ -> time f)
    |> Array.average

成本与选择

首先看创建成本。这里用bigint数据类型,就是为了增加一点创建成本。可以很清楚的看到,对于非常大、非常大的数据集合,Array和List的代价不小,而seq的创建简直是无痛。

let arr = [|bigint 1 .. bigint 200000|]
[|  timeN 100 (fun () -> Seq.ofArray arr);
    timeN 100 (fun () -> seq{ bigint 1 .. bigint 200000});
    timeN 100 (fun () -> [|bigint 1 .. bigint 200000|])
    timeN 100 (fun () -> [bigint 1 .. bigint 200000])
|]
[ 0.0022629999999999985, 0.0006500000000000007, 96.561791, 85.33365899999998 ]
let firstInList pred l =
    try
        Some (List.find pred l)
    with
        | :? System.Collections.Generic.KeyNotFoundException -> None

let firstInSeq pred s =
    try
        Some (Seq.find pred s)
    with
        | :? System.Collections.Generic.KeyNotFoundException -> None

let l = List.ofArray arr
let s = Seq.ofArray arr

[|  timeN 100 (fun () -> firstInSeq (fun x -> x > bigint 100000) s)
    timeN 100 (fun () -> first (fun x -> x > bigint 100000) arr)
    timeN 100 (fun () -> firstInList (fun x -> x > bigint 100000) l)
|]

 [ 15.476051999999997, 11.969942000000003, 11.619427999999997 ]

至于运行时间,根据帮助,三者都是O(N),测试结果也很接近。

但是如果考虑原始数据的构造,采用seq{bigint 1 .. bigint 200000}优势就太明显了。帮助文档上说,seq中的元素只有在实际用到时才会产生,针对于集合很大但是只会利用其中一部分的值的场景就非常适合。

毕竟,seq是能够产生无穷集合的……

[|
    timeN 100 (fun () -> Seq.item 200000 (Seq.initInfinite (fun x -> bigint x)))
    timeN 100 (fun () -> Array.item 200000 [|bigint 1 .. bigint 200001|])
|]

[ 1.2698740000000006, 84.78620000000001 ]

从上面的结果还有一个很好玩的结果,可以看到Seq根本没有费力把所有的值都存着,即用即弃。

总结

  1. 函数式编程的核心是抽象动作,把一个值映射为另一个值的动作。
  2. 函数式编程中处理的值包括数值、集合、函数,这是一个核心的概念。
  3. 值+函数=程序。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

大福是小强

除非你钱多烧得慌……

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值