【C#/F#】「函数式编程」第一讲:高阶函数、纯函数

说到函数式编程,函数自然在其中扮演着举足轻重的角色,本讲开始,我们将会去尝试理解在函数式编程中一些重要的函数概念

高阶函数(higher-order function)

在函数是第一类公民的语言中,我们可以做到一件比较有趣的事。即函数可以作为入参或者出参而存在。而高阶函数呢,则是入参或出参是函数或兼而有之的函数,这件事在我们序章的时候其实已经做到过一次了,没错,就是SwapArgs

Func<int, int, bool> SwapArgs(Func<int, int, bool> func)
{    
  return (a, b) => func(b, a);
}

类似SwapArgs这种类型的函数也可以叫做适配器函数,我们可以按自己喜好去修改函数的入参。不知读者是否之前想过函数的定义是否能够修改的问题,在这里适配器函数告诉我们,函数的入参等并不是一成不变的。

在我们最常见的功能中,Linq就是我们最常见的,基于扩展函数实现的一系列的高阶函数。

扩展函数本身的特性可以更好的适配函数组合这个概念,我们可以使用这个特性让我们获得更好的函数式体验(如果读者有阅读过微软文档,可能会看到在允许的情况下直接将方法放入类中会更好。但这并不总是正确的,函数式编程提倡数据与逻辑的分离,我们更推荐去使用一些静态类,静态方法。关于这一点我们后续会说到)

那么下面让我们来看Linq是怎么使用的

int[] seq = Enumerable.Range(1, 100).ToArray();
var evens = seq.Where(i => i % 2 == 0); 
Console.WriteLine(string.Join(",", evens.Take(5)));

我们通过输入了一个lambda表达式(函数),描述了我们应该怎么处理数据,于是我们就获得了0-100之间可以被2整除的数。where函数就像谓语一样,描述了我们应该做什么。这,就有了可读性的提升,不过我觉得还不够,我们还有办法再提升一点可读性吗?

Func<int, bool> IsMod(int n) => x => x % n == 0; // 高阶函数
evens = seq.Where(IsMod(2));
Console.WriteLine(string.Join(",", evens.Take(5)));

我们还可以用函数生成函数(高阶函数)进一步的去简化代码,增强可读性,也兼具一些通用性。

var modby3 = seq.Where(IsMod(3));
Console.WriteLine(string.Join(",", modby3.Take(5)));

Linq的强大性只要使用过或许都会有着深刻的印象,

如果我们想自己实现一些类似Linq的函数,yield(迭代器)关键字也可以轻松的帮我们实现我们的目的

public static class MyLinq
{
    public static IEnumerable<TSource> MyWhere<TSource, TKey>(
        this IEnumerable<TSource> source, Func<TSource, bool> predicate)
    {
        foreach (var item in source)
        {
            if (predicate(item))
                yield return item;
        }
    }
}

是不是比想象中要简单不少呢?

我们还可以利用高阶函数避免一些代码的重复性

例如我们想要在发送数据的时候就建立一次socket连接(这里只是举例,类似数据库可能更符合这种需求)

如果我们平常的写法,可能会有重复的建立连接和释放连接的过程

public class NormalSocket
{
    public byte[] SendHelloWorld(string ip, int port)
    {
        // 反复的using代码与连接代码
        using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        {
            socket.Connect(ip, port);
            var data = Encoding.UTF8.GetBytes("Hello World");
            socket.Send(data);
            var buffer = new byte[1024];
            var count = socket.Receive(buffer);
            return buffer.Take(count).ToArray();
        }
    }
    public byte[] SendTwoHelloWorld(string ip, int port)
    {
        using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        {
            socket.Connect(ip, port);
            var data = Encoding.UTF8.GetBytes("Hello World");
            socket.Send(data);
            socket.Send(data);
            var buffer = new byte[1024];
            var count = socket.Receive(buffer);
            count = socket.Receive(buffer);
            return buffer.Take(count).ToArray();
        }
    }
}

但如果我们使用了高阶函数,则可以通过生成函数的方式,来避免这个问题

public static class HofSocket
{
    public static byte[] SendSth(string ip, int port, Action<Socket> send)
    {
        return Connect(ip, port)(send);
    }
    public static byte[] SendHelloWorld(string ip, int port)
    {
        return Connect(ip, port)(socket => {
            var data = Encoding.UTF8.GetBytes("Hello World");
            socket.Send(data);
        });
    }
    public static Func<Action<Socket>, byte[]> Connect(string ip, int port)
    {
        return send => {
            using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
            {
                socket.Connect(ip, port);
                send(socket);
                var buffer = new byte[1024];
                var count = socket.Receive(buffer);
                return buffer.Take(count).ToArray();
            }
        };
    }
    public static byte[] Send2Localhost( Action<Socket> send)
    {
        return Connect("localhost", 8080)(send);
    }
}

可以看到,我们通过Connect生成了一个函数,再通过传入一个函数,操作我们所需要的socket,这样便可以轻松的实现我们想要的功能,而无需重复性的连接代码。(事实上采用异步可能会更符合实际的情况,但异步问题会使代码变得复杂,无助于我们学习函数式编程的本质,这里我们先跳过)

如果读者对最早的介绍还有印象,你好,F#! 你好,函数式编程!-优快云博客
,可能会联想到一个叫做柯里化的概念。是的,本质上我们确实是在做类似的工作,如果改一改我们的代码,可以写成这样的形式

var sendSth = (string ip) => (int port) => (Func<Socket, byte[]> send) => {
    using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
    {
        socket.Connect(ip, port);
        return send(socket);
    }
}; 
// 类型推断过于复杂,不适合显式声明
// Func<string, Func<int, Func<Func<Socket, byte[]>, byte[]>>>

但是我们很难将这个函数比较轻松的定义到C#的类中,因为我们缺失了字段或者属性的类型推断功能。

是时候让我们看向F#了

let swapArgs f = fun a b -> f b a
// val swapArgs: f: ('a -> 'b -> 'c) -> a: 'b -> b: 'a -> 'c

非常的简洁,这就是我们最开始提到的适配器函数,强大的类型推断能力让我们能非常轻松的做到这一点。

fun在F#中起到了类似C#中lambda的作用

let data = [1..100] // 0,1....100
let isMod n = fun c -> c % n = 0
data |> List.filter (fun c -> c % 2 = 0) |> printfn "%A"
data |> List.filter (isMod 2) |> printfn "%A"

这是在F#之中类似C#Linq的功能,但是是更纯粹的函数式的表达,与扩展函数相比,我们可以注意到事实上参数顺序有一些不一样

如果写成我们更熟悉的形式的话如下

printfn ("%A") (List.filter (isMod 2) data)

看起来更复杂了,可读性也降低了。不过我们还是可以注意到,相比于C#中扩展函数将处理对象放在第一个入参,F#中将其放在最后,这与语言的处理方式有关,放在最后的方式,更有利于组合的使用。(这可以算是设计的一个小tips)

module MyLinq = 
     let myWhere f l = [for x in l do if f x then yield x]

图片

是的,这个就是在F#中实现我们刚刚自己的linq函数。看起来有些疯狂。诚然,有一些变量名的缩写让代码变得更短,但不可否认的是强大的类型推断能力赋予了我们编写极简代码的能力。同样的,yield关键字也发挥了重要的作用。

open System.Net.Sockets
module HofSocket =
    let sendSth (ip: string) port f =
        use socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
        socket.Connect(ip, port)
        f socket |> ignore
        let buffer = Array.create 1024 0uy
        let cnt = socket.Receive buffer
        buffer |> Array.take cnt
        
        
    let send2Localhost = sendSth "127.0.0.1" 8080
    let sendHelloWorld = send2Localhost (fun s -> s.Send(System.Text.Encoding.ASCII.GetBytes("Hello World!")))

自带的函数柯里化让我们能更好的实现这一功能,代码也十分简洁。

F#本身的语言特性会在专门的栏目详细介绍,但我依然是这个观点,当学会函数式编程后,F#自然而然的就学会了,这是一件非常有意思的事情,也希望读者能耐心的度过前期较为痛苦的思路转换期。


回到上一讲的最后,我们提到了一个问题。

如果一个函数的输入一致时,输出一定一致,且不返回void的时候,这时候函数变得更接近什么概念了?

时光回到我们最初学习编程,当我们最初听到函数这个概念的时候。恐怕我们都会将其与数学意义上的函数的概念重合起来。事实上这两者在最初的时候确实也非常的像。

纯函数(pure function)

将一个输入,映射到一个输出。

随着我们逐步深入,编程中的函数开始偷偷的放肆,没有边界了起来。它开始可以去修改外部的变量,或是直接修改输入的参数,或者输出一些东西,甚至删除一些东西,又或是每次调用都给你不一样的结果,或是根本不给你结果,或是直接一个错误直接中断了函数。类比一下的话,就好像我们使用数学函数的时候,其中竟然有一个步骤是让你把考卷撕了,或者会在试卷上写一些东西,然后最后还没有任何结果!

Paper paper = new Paper();
int ReturnOne(Student student) {
    paper.Delete();
    student.Sleep();
    throw new Exception("想要1吗 不给你");
}
paper.Write(ReturnOne(me));

有这样的函数就给我烤鸡蛋吧(逃)

如果在数学中遇到这种函数我们肯定要疯了!哪有这样的函数。但是编程中我们的思维却被潜移默化的改变了,这是为什么呢?

显然,在编程中这样的函数能够做到的事情太多了,太自由了,在一定程度上也能为我们实现功能提供方便。所以我们自然而然的接受了这些能力。但事实上,这如同Null一样,这些副作用可能是是洪水猛兽,当使用了有副作用的代码,就有可能给我们的程序中带来隐患。

我们在这里完整的定义一下副作用,我们把这类会影响外界状态(状态突变)、使用到了IO(输入输出)的功能、改变输入参数,或是抛出异常叫做副作用

但副作用不同于Null, 我们能完全避免副作用吗?

显而易见,不行, 如果改变输入参数与抛出异常的副作用我们还可以想象如何去避免(在之后后我们会说到如何管理异常)。完全不发生状态突变,这事实上也是有可能的,尽管这可能会有些不可思议。

但对于IO来说,我们可能我们不可能完全避免IO的存在。IO不总是会得到相同的输入或输出,但这是副作用吗?是副作用,但这可能更像是需求就是这个副作用(例如读取时间,读取网页数据)。完全没有副作用的程序是几乎不可能的,很多功能都依赖着副作用实现。我们应该做的,是管控副作用所发生的范围。

对于没有副作用,且输入一致时输出总是一致的函数(一个x对应一个y),我们称之为纯洁的函数(纯函数),这也非常接近数学概念上的函数。

这下我们再思考就能发现,数学上的函数,当然怎么并行都无所谓,从没听说过数学的函数不能同时运算(调用)的说法,而且他也很方便测试,因为输出只和输入挂钩,这当然很容易就能完成测试,不会有其他任何因素影响我们的函数,我们只需要考虑输入。相反,如果我们需要测试不纯函数,我们还需要去还原运行前后外界的状态(因为可能有状态突变),也可能函数根本就不返回值,我们只能观察他的输出或者抛出的错误才可以确定,这自然就很难测试了。

我们可以通过一个简单例子直观的了解纯函数与不纯函数的区别

public static class WhoPure
{
    static int sth = 0;
    public static int NotPureAddReturn()
    {
        return sth++;
    }
    public static int GetSth() => sth;
    public static void NotPureAdd()
    {
        sth++;
    }
    public static int PureAdd(this int num) => num + 1;
}

Console.WriteLine(WhoPure.NotPureAddReturn());
Console.WriteLine(WhoPure.NotPureAddReturn());
Console.WriteLine(WhoPure.NotPureAddReturn());
Console.WriteLine(WhoPure.NotPureAddReturn());
Console.WriteLine(1.PureAdd());
Console.WriteLine(1.PureAdd());
Console.WriteLine(1.PureAdd().PureAdd());

// 输出
// 0
// 1
// 2
// 3
// 2
// 2
// 3

通过以上的学习我们了解到了高阶函数,纯函数,不过我们还没有结束。再思考一个问题,我们该如何定义一个函数?我们定义了一个函数后,函数是否就会诚实的执行我们的定义?

这个问题,就留到我们下一讲来解答吧,同时我们的老“朋友”Null大魔王也会再次光临!但是这次他还能这么强大吗?

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值