使用C#進行函數式編程
提起函數式編程,大家一定想到的是語法高度靈活和動態的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>> 類型,我們還可以讀取到具體的屬性名稱等信息。