C#函數式程序設計初探——基礎理論篇


篇首語

  近來發現園子里有不少人在討論函數式相關的問題,從個人性格來講,我不愛看學術氣氛太強的東西,從責任上來講,我認為也有必要寫一篇“干貨”把函數式這個問題說得明白一些,也作為自己的一個知識沉淀,於是便有了此文。

  個人認為,C#語言的某些設計並不非常適合函數式開發,比如它的類型推斷並不是很近乎人意,我們知道C#還是主打面向對象的,不過這並不妨礙我們用C#來討論函數式,至少可以借鑒函數式的一些思路來優化我們的代碼。

  我希望通過這篇文章讓讀者通過簡單的例子,在短時間內掌握基本函數式編程方法,了解Action與Func類型的使用。同時我希望讀者對C#泛型集合、Linq、lambda表達式和yield關鍵字有所了解。

 

主要內容

  Action與Func類型介紹,在函數內部定義函數與返回函數,閉包與函數柯里化,高階函數與Linq應用。

 

第一部分 Action與Func類型介紹

  近來有一些人問我Action和Func類型是什么意思,為了整篇文章知識體系的完整性,先來給大家做一番介紹(如果你熟悉這兩個類型,請跳過這部分)。

  首先來看這樣一個JavaScript函數:

function sum(n1, n2) {
    return n1 + n2;
}

我們知道,在JavaScript當中,函數是可以賦值為一個變量的,即:

var sum = function(n1, n2) {
    return n1 + n2;
}

定義這個“變量”之后,我們可以通過sum(1,2)的方式調用這個函數。那么,如果javaScript是一種強類型語言的話,這個var是什么類型呢?

來看一下這個函數的C#代碼:

static int Sum(int n1, int n2)
{
    return n1 + n2;
}

注意到這個函數接收了兩個int型參數,返回了一個int值。那么,它的類型就是Func<int,int,int>,即它的等效代碼為:

Func<int,int,int> Sum = (int n1, int n2) => {
    return n1 + n2;
};

我們可以F12一下,看到Func類的定義如下:

public delegate TResult Func<in T1, in T2, out TResult>(T1 arg1, T2 arg2);

  這個類型實質上是一個委托,返回值是個泛型的TResult,從定義的參數表可以看出,前兩個類型T1和T2是傳入參數的類型,第三個類型是返回值類型。

  根據這個道理,假設有一個Func<int,string,bool>型的變量,它表示一個委托,這個委托內包含了這樣一個函數:該函數的兩個參數是int和string類型,返回值為bool。當Func<TResult>只有一個類型參數時,TResult表示返回值類型,即Func<bool>表示一個委托,它的參數表為空,返回值為bool類型。為了方便說明,下文將委托與函數兩個概念通通使用“函數”來表示。

  猜猜看Func<object, Func<string,bool>>表示什么呢?它表示一個函數,接受一個object類型的參數,返回一個Func<string,bool>。這里可以看出,函數也是可以作為函數的返回值的。

  接下來看Action,我們F12一下看Action<T>的定義:

public delegate void Action<in T>(T obj);

注意到委托的返回值為void,那么實際上Action就是一個沒有返回值,只有參數表的委托,即Action<T1,T2>等價於Func<T1,T2,void>。

  最后來說一下Predicate<T>,當我們寫Linq方法的Where()時,可以看到它要求傳入了一個Predicate類型的參數,它實際上就是一個bool型委托,等價於Func<T,bool>。

  這里只是對這幾個委托關鍵字做一個鋪墊性的介紹,大家可以去網上搜這兩個關鍵字的用法相關的帖子,如果沒搞懂請不要往下看。 

 

第二部分 在函數內部定義函數與返回函數

  那么有人該問了,好好的一個函數,干嘛非寫成Func這樣蹩腳的形式呢?下面來看一個例子:

static void DoSth()
{
    //前置邏輯
    if (Validate())
    { 
        //后續邏輯
    }
}

static bool Validate()
{
    //校驗邏輯
    return true;
}

  也許這個例子不夠恰當,但是足以說明問題,我想略有經驗的程序員都明白將校驗方法(或者說,比較方法,嗯)重構到一個新函數里,這樣能讓程序脈絡清晰,《重構》當中也提到了這一手段,但是有沒有意識到這種做法有一個詬病:這兩個方法處於一個類環境當中,通常來說DoSth方法是publish的,那么為了重構,我們不得不在這個類環境當中搞出一個private方法來支撐這個public方法,顯然這個private方法沒有什么可復用性可言,而且它污染了整個類空間,再說從面向對象的角度來看,校驗成了我這個類要承擔的職責,這豈不是很詭異?

  那么我要做的,就是在提取這個Validate方法的前提下,保證這個方法別污染類空間。那么一個切實可行的辦法,就是把這個校驗函數定義在DoSth的內部,代碼如下:

static void DoSth()
{
    Func<bool> Validate = () => {
        //校驗邏輯
        return true;
    };

    //前置邏輯
    if (Validate())
    { 
        //后續邏輯
    }
}

  這段代碼把校驗函數定義為了DoSth內部的一個變量,它的生存期就在DoSth內部,這樣一來就絲毫不會影響類的結構了。這就是Func的應用之一——在函數內部定義局部函數。

  但是這樣還是讓人覺得很啰嗦,這個Validate完全可以在其他地方定義,然后作為參數傳進來,比如這樣:

static void DoSth(Func<bool> Validate)
{
    //前置邏輯
    if (Validate())
    {
        //后續邏輯
    }
}

如此一來,這個校驗方法就可以定義在其他地方了,這就給我們做一些面向對象方面的方便(比如通過依賴注入搞到這個函數),當然也可以在調用的時候直接在參數里寫lambda表達式:

static void Main(string[] args)
{
    DoSth(() => { 
        //校驗邏輯
        return true;
    });
}

  可能這個“校驗”的例子舉得不是很恰當,但是這已經足夠說明Func作為參數的用法。

  如果你懷疑這種手段的實際價值,想想JavaScript里的SetTimeout的第二個參數吧!所謂的回調函數,就是一種由框架調用由客戶端實現的函數,用這種寫法可以大大增加客戶端代碼的直觀性與靈活性!

  既然Func類型可以作為函數的參數,那么它可不可以作為函數返回值呢?答案必然是肯定的,我們還是來看一個加法例子:

static Func<int, Func<int, int>> Sum = n1 => {
    return n2 => n1 + n2;
};

觀察返回值類型Func<int, Func<int,int>>,它表示這個函數接受一個int型參數,返回一個Func<int,int>,也就是返回一個接受int類型參數,返回int類型值的函數。即,Sum是一個返回函數的函數。

那么這個函數如何使用呢?觀察下列主函數:

static void Main(string[] args)
{
    var Sum5 = Sum(5);
    int result = Sum5(10);

    Console.WriteLine(result);
    Console.ReadKey();
}

首先,我們通過Sum(5)的方式,返回了一個Sum5變量,這個變量的類型是Func<int,int>,也就是說,我們通過Sum函數返回了Sum5函數。接下來調用這個新函數Sum5(10),得到了答案15。當然,接下來我還可以調用Sum5(20)得到25。

  自然地,這個調用可以寫成Sum(5)(10),與原本的Sum(5,10)相比,新的寫法將兩個參數拆解到了多個括號之中分部調用。聰明的你一定能發現這么做的好處,就是把這個參數解耦,讓各個算法(函數)之間有更高的靈活性和可復用性。但是要注意的是,要得到最終的結果,參數的數量依舊是一個都不能少的。

  另外,你有沒有從這里嗅出一些“重載”的味道?

 

第三部分 閉包與函數柯里化

  不要被這個標題嚇倒,嗯!我們來改寫一下剛才的代碼:

static void Main(string[] args)
{
    var Sum5 = Sum();
    int result = Sum5(10);

    Console.WriteLine(result);
    Console.ReadKey();
}

static Func<Func<int, int>> Sum = () => {
    int n1 = 5;
    return n2 => n1 + n2;
};

這次我們讓Sum不再接收第一個參數了,而把n1定義在Sum方法的內部,調用就變成了Sum()(10),大家可以試一下,結果依舊輸出15,一切看似很自然,不過請你反復讀一讀Sum的定義,是不敢覺得似乎少了點什么?希望你停下來多讀幾遍再往下看!

問題就出在n1的定義,請回答一個問題,變量n1的生存范圍是多大?Sum函數返回的時候,n1既然是Sum的內部的局部變量,應該就被釋放掉了,那么我調用Sum5(10)的時候,被釋放掉的5是從哪里來的呢?

在解釋這個問題之前,我想你應該可以理解“Func<Func<int,int>> Sum = xxx”這種寫法,等價於“Func<int,int> Sum() { xxx }”,如果不理解,請停下來,把上面的部分再看一遍。

我們打開反編譯器對這個Sum的定義,可以看到:

[CompilerGenerated]
private static Func<int, int> <.cctor>b__0()
{
    <>c__DisplayClass3 CS$<>8__locals4;
    return new Func<int, int>(CS$<>8__locals4, (IntPtr) this.<.cctor>b__1);
}

奇怪的是,在這個函數的第一句話,定義了一個“<>c__DisplayClass3”匿名類的對象,也就是說,Sum5這個函數的內部攜帶着這個對象,想必5這個數字就保存在這個類里,來看這個類的定義:

[CompilerGenerated]
private sealed class <>c__DisplayClass3
{
    public int n1;

    public int <.cctor>b__1(int n2)
    {
        return (this.n1 + n2);
    }
}

看到這里我想我不用再解釋什么了吧。

  觀察我們的函數n2=>n1+n2,它能夠拿到外部函數Sum中的n1,而Sum卻不能拿到它內部的n2,這一類的函數,起個名字——閉包。於是現在你稍微理解JavaScript中那個叫作用域鏈的東西了嗎?

  嗯,這部分的標題上提到了函數的柯里化,那什么是柯里化呢?其實剛才已經看過了,把Sum(5,10,15,20)寫成Sum(5)(10)(15)(20)就叫柯里化,或者說把Func<int,int,int>搞成Func<int, Func<int,int>>就叫柯里化,也是起個名字唬人的,就像“面向切面編程”這個名字一樣!

 

第四部分 高階函數與Linq應用

  現在進入理論篇的最后一部分,神馬叫高階函數?還就是起個名字而已,以其他函數做參數、或者返回一個函數的函數,就叫高階函數,剛才的Sum就是高階函數。至此大家已經了解了如何在函數中調用一個作為參數的函數,為了給后面的應用篇做鋪墊,這里介紹幾個經典的高階函數,希望大家都能理解。

  (1)Map函數:接受一個轉換函數和一個集合,對這個集合中的每個元素,延遲返回它執行轉換函數后的值。

static IEnumerable<TR> Map<T, TR>(Converter<T, TR> select, IEnumerable<T> list)
{
    foreach (T val in list)
    {
        yield return select(val);
    }
}

其中Converter是一個委托,它接受一種類型的參數,返回另一種類型的參數,也就是說如果有一個Converter類型的函數,其作用就是將一種類型轉換為另一種類型,當然,在使用的時候,我們可以傳遞一個很復雜的類,返回其中的某個字段。

public delegate TOutput Converter<in TInput, out TOutput>(TInput input);

  (2)Filter函數:接受一個布爾函數作為判斷條件,作用在一個集合上,延遲返回這個集合當中滿足條件的元素。

static IEnumerable<T> Filter<T>(Predicate<T> selector, IEnumerable<T> list)
{
    foreach (T val in list)
    {
        if (selector(val))
        {
            yield return val;
        }
    }
}

  (3)Fold函數:接受一個返回TR類型的算法函數,一個TR類型的起始值,及一個集合,對這個集合中的所有值應用這一算法,並”折疊“到返回值上返回。

static TR Fold<T, TR>(Func<TR, T, TR> accumulator, TR startVal, IEnumerable<T> list)
{
    TR result = startVal;
    foreach (T val in list)
    {
        result = accumulator(result, val);
    }
    return result;
}

  大家有沒有看出這三個函數有什么貓膩?它們都有一個IEnumerable<T>的參數,那么下面我們就把他們改造為擴展方法,並且改個名:

public static partial class Enumerable
{
    public static IEnumerable<TR> Select<T, TR>(this IEnumerable<T> list, Converter<T, TR> selectField)
    {
        foreach (T val in list)
        {
            yield return selectField(val);
        }
    }

    public static IEnumerable<T> Where<T>(this IEnumerable<T> list, Predicate<T> selector)
    {
        foreach (T val in list)
        {
            if (selector(val))
            {
                yield return val;
            }
        }
    }

    public static TR Sum<T, TR>(this IEnumerable<T> list, Func<TR, T, TR> accumulator, TR startVal)
    {
        TR result = startVal;
        foreach (T val in list)
        {
            result = accumulator(result, val);
        }
        return result;
    }
}

我們可以這樣使用:

static void Main(string[] args)
{
    IEnumerable<int> list = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8 };

    list.Where(num => num % 2 == 0)
        .Select(num => num)
        .ToList().ForEach(num => {  //這里就直接調用Linq了
            Console.WriteLine(num);
        });

    int sum = list.Where(num => num % 2 == 0)
        .Sum((x, y) => x + y, 0);
    Console.WriteLine("sum=" + sum);

    Console.ReadKey();
}

這基本和Linq沒有什么差別了,嗯,其實Linq里就是這么搞的,只是它更加豐富和嚴謹,依舊不用多解釋了。

 

后記

  相信大家讀完這篇文章之后已經對函數式編程有了一個初步的認識,函數式還有很多精彩的應用,請關注下回分解!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM