C#函數語言程式設計思想及案例

richieyang發表於2015-04-06

提起函數語言程式設計,大家一定想到的是語法高度靈活和動態的LISP,Haskell這樣古老的函式式語言,往近了說ruby,javascript,F#也是函數語言程式設計的流行語言。然而.net自從支援了lambda表示式,C#雖然作為一種指令式程式設計語言,在函式性程式設計方面也毫不遜色。我們在使用c#編寫程式碼的過程中,有意無意的都會使用高階函式,組合函式,純函式快取等思想,連表示式樹這樣的idea也來自函數語言程式設計思想。所以接下來我們把常用的函數語言程式設計場景做個總結,有利於我們在程式設計過程中靈活應用這些技術,擴充我們的設計思路和提高程式碼質量。

一、高階函式

高階函式通俗的來講:某個函式中使用了函式作為引數,這樣的函式就稱為高階函式。根據這樣的定義,.net中大量使用的LINQ表示式,Where,Select,SelectMany,First等方法都屬於高階函式,那麼我們在自己寫程式碼的時候什麼時候會用到這種設計?

舉例:設計一個計算物業費的函式,var fee=square*price, 而面積(square)根據物業性質的不同,計算方式也不同。民用住宅,商業住宅等需要乘以不同的係數,根據這樣的需求我們試著設計下面的函式:

民用住宅面積:

 public Func<int,int,decimal> SquareForCivil()
        {
            return (width,hight)=>width*hight;
        }

商業住宅面積:

public Func<int, int, decimal> SquareForBusiness()
        {
            return (width, hight) => width * hight*1.2m;
        }

這些函式都有共同的簽名:Func<int,int,decimal>,所以我們可以利用這個函式簽名設計出計算物業費的函式:

public decimal PropertyFee(decimal price,int width,int hight, Func<int, int, decimal> square)
        {
            return price*square(width, hight);
        }

是不是很easy,寫個測試看看

[Test]
        public void Should_calculate_propertyFee_for_two_area()
        {
            //Arrange
            var calculator = new PropertyFeeCalculator();
            //Act
            var feeForBusiness= calculator.PropertyFee(2m,2, 2, calculator.SquareForBusiness());
            var feeForCivil = calculator.PropertyFee(1m, 2, 2, calculator.SquareForCivil());
            //Assert
            feeForBusiness.Should().Be(9.6m);
            feeForCivil.Should().Be(4m);
        }

二、惰性求值

C#在執行過程使用嚴格求值策略,所謂嚴格求值是指引數在傳遞給函式之前求值。這個解釋是不是還是有點不夠清楚?我們看個場景:有一個任務需要執行,要求當前記憶體使用率小於80%,並且上一步計算的結果<100,滿足這個條件才能執行該任務。

我們可以很快寫出符合這個要求的C#程式碼:

public double MemoryUtilization()
        {
            //計算目前記憶體使用率
            var pcInfo = new ComputerInfo();
            var usedMem = pcInfo.TotalPhysicalMemory - pcInfo.AvailablePhysicalMemory; 
            return (double)(usedMem / Convert.ToDecimal(pcInfo.TotalPhysicalMemory));
        }

        public int BigCalculatationForFirstStep()
        {
            //第一步運算
            System.Threading.Thread.Sleep(TimeSpan.FromSeconds(2));
            Console.WriteLine("big calulation");
            FirstStepExecuted = true;
            return 10;
        }

        public void NextStep(double memoryUtilization,int firstStepDistance)
        {
	   //下一步運算
            if(memoryUtilization<0.8&&firstStepDistance<100)
            {
                Console.WriteLine("Next step");
            }
        }

在執行NextStep的時候需要傳入記憶體使用率和第一步(函式BigCalculatationForFirstStep)的計算結果,如程式碼所示,第一步操作是一個很費時的運算,但是由於C#的嚴格求值策略,對於語句if(memoryUtilization<0.8&&firstStepDistance<100)來講,即使記憶體使用率已經大於80%了,第一步操作還得執行,很顯然,如果記憶體使用率大於80%,值firstStepDistance已經不重要了,完全可以不用計算。

所以惰性求值是指:表示式或者表示式的一部分只有當真正需要它們的結果時才會對它們進行求值。我們嘗試用高階函式來重寫這個需求:

public void NextStepWithOrderFunction(Func<double> memoryUtilization,Func<int> firstStep)
        {
            if (memoryUtilization() < 0.8 && firstStep() < 100)
            {
                Console.WriteLine("Next step");
            }
        }

程式碼很簡單,就是用一個函式表示式來代替函式值,如果if (memoryUtilization() < 0.8..這句不滿足,後面的函式也不會執行。微軟在.net4.0版本中加入了Lazy<T>類,大家可以在有這種需求的場景下使用這個機制。

三、函式柯里化(Curry)

柯里化也稱作區域性套用。定義:是把接受多個引數的函式變換成接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下的引數且返回結果的新函式的技術,ps:為什麼官方解釋這麼繞口?

看到這樣的定義估計大家也很難明白這是這麼一回事,所以我們從curry的原理講起:

寫一個兩個數相加的函式:

public Func<int, int, int> AddTwoNumber()
        {
            return (x, y) => x + y;
        }

ok, 如何使用這個函式?

var result= _curringReasoning.AddTwoNumber()(1,2);

1+2=3,呼叫很簡單。需求升級,我們需要一個函式,這個函式要求輸入一個引數(number),算出10+輸入的引數(number)的結果。估計有人要說了,這需求上面的程式碼完全可以實現啊,第一個引數你傳入10不就完了麼,ok,如果你是這樣想的,我也是無可奈何。還有人可能說了,再寫一個過載,只要一個引數即可,實際情況是不容許,我們在呼叫別人提供的api,無法新增過載。可以看到區域性套用的使用場景不是一種很普遍的場景,所以在合適的場景配合合適的技術才是最好的設計,我們來看區域性套用的實現:

public Func<int, Func<int, int>> AddTwoNumberCurrying()
        {
            Func<int, Func<int, int>> addCurrying = x => y => x + y;
            return addCurrying;
        }

表示式x => y => x + y得到的函式簽名為Func<int, Func<int, int>>,這個函式簽名非常清楚,接收一個int型別的引數,得到一個Func<int,int>型別的函式。此時如果我們再呼叫:

//Act
            var curringResult = curringReasoning.AddTwoNumberCurrying()(10);
            var result = curringResult(2);

            //Assert
            result.Should().Be(12);

這句話:var curringResult = curringReasoning.AddTwoNumberCurrying()(10); 生成的函式就是隻接收一個引數(number),且可以計算出10+number的函式。

同樣的道理,三個數相加的函式:

public Func<int,int,int,int> AddThreeNumber()
        {
            return (x, y, z) => x + y + z;
        }

區域性套用版本:

public Func<int,Func<int,Func<int,int>>> AddThreeNumberCurrying()
        {
            Func<int, Func<int, Func<int, int>>> addCurring = x => y => z => x + y + z;
            return addCurring;
        }

呼叫過程:

 [Test]
        public void Three_number_add_test()
        {
            //Arrange
            var curringReasoning = new CurryingReasoning();

            //Act
            var result1 = curringReasoning.AddThreeNumber()(1, 2, 3);
            var curringResult = curringReasoning.AddThreeNumberCurrying()(1);
            var curringResult2 = curringResult(2);
            var result2 = curringResult2(3);

            //Assert
            result1.Should().Be(6);
            result2.Should().Be(6);
        }

當函式引數多了之後,手動區域性套用越來越不容易寫,我們可以利用擴充套件方法自動區域性套用:

 public static Func<T1, Func<T2, TResult>> Curry<T1, T2, TResult>(this Func<T1, T2, TResult> func)
        {
            return x => y => func(x, y);
        }

        public static Func<T1, Func<T2, Func<T3, TResult>>> Curry<T1, T2, T3, TResult>(this Func<T1, T2, T3,TResult> func)
        {
            return x => y => z=>func(x, y,z);
        }

同樣的道理,Action<>簽名的函式也可以自動套用

有了這些擴充套件方法,使用區域性套用的時候就更加easy了

[Test]
        public void Should_auto_curry_two_number_add_function()
        {
            //Arrange
            var add = _curringReasoning.AddTwoNumber();
            var addCurrying = add.Curry();

            //Act
            var result = addCurrying(1)(2);

            //Assert
            result.Should().Be(3);
        }

好了,區域性套用就說到這裡,stackoverflow有幾篇關於currying使用的場景和定義的文章,大家可以繼續瞭解。

函數語言程式設計還有一些重要的思想,例如:純函式的快取,所為純函式是指函式的呼叫不受外界的影響,相同的引數呼叫得到的值始終是相同的。尾遞迴,單子,程式碼即資料(.net中的表示式樹),部分應用,組合函式,這些思想有的我也仍然在學習中,有的還在思考其最佳使用場景,所以不再總結,如果哪天領會了其思想會補充。

四、設計案例

最後我還是想設計一個場景,把高階函式,lambda表示式,泛型方法結合在一起,我之所以設計這樣的例子是因為現在很多的框架,開源的專案都有類似的寫法,也正是因為各種技術和思想結合在一起,才有了極富有表達力並且非常優雅的程式碼。

需求:設計一個單詞查詢器,該查詢器可以查詢某個傳入的model的某些欄位是否包含某個單詞,由於不同的model具有不同的欄位,所以該查詢需要配置,並且可以充分利用vs的智慧提示。

這個功能其實就兩個方法:

private readonly List<Func<string, bool>> _conditions; 

public WordFinder<TModel> Find<TProperty>(Func<TModel,TProperty> expression)
        {
            Func<string, bool> searchCondition = word => expression(_model).ToString().Split(' ').Contains(word);
            _conditions.Add(searchCondition);
            return this;
        }

        public bool Execute(string wordList)
        {
            return _conditions.Any(x=>x(wordList));
        }

使用:

 [Test]
        public void Should_find_a_word()
        {
            //Arrange
            var article = new Article()
            {
                Title = "this is a title",
                Content = "this is content",
                Comment = "this is comment",
                Author = "this is author"
            };

            //Act
            var result = Finder.For(article)
                .Find(x => x.Title)
                .Find(x => x.Content)
                .Find(x => x.Comment)
                .Find(x => x.Author)
                .Execute( "content");

            //Assert
            result.Should().Be(true);
        }

該案例本身不具有實用性,但是大家可以看到,正是各種技術的綜合應用才設計出極具語義的api, 如果函式引數改為Expression<Func<TModel,TProperty>> 型別,我們還可以讀取到具體的屬性名稱等資訊。

相關文章