昨天講到了定義域,今天換來討論值域。理想上一個方法都希望有一個回傳值,但現實中很難做到,就算已經在先驗條件中把所有未定義的輸入都排除了,仍然會有狀況是導致沒辦法得到對應的輸出(寫久了發現這通常牽扯到複雜的外部行為,以函數式風格來說就是不純),通常我們會使用null去解決這個問題。舉例來說,我要從資料庫中取出某個會員的資料,程式碼大概會這樣寫:
var person = _dbContext.Member.FirstOrDefault(x => x.Id == id);
Console.WriteLine(person.Name);
當取不到資料的時候,person就會是null reference的,概念上指向”空”,而後面如果沒有特別的處理,程式就會發生意料之外的崩潰。如果有維運過龐大的系統,應該多多少少有經驗處理過因為出乎意料的null reference造成的系統錯誤,花了大把精力排查問題才找到問題源頭。
那麼有沒有可能不使用null呢?我們再回來看看如何用函數表示回傳沒有回傳結果,感覺上好像很矛盾,但其實可以換一個念頭,所謂的沒有回傳結果就是一個結果,用圖的話可以這樣表示:
不要忘了,函數也可以是多對一的,只要將None作為值域的一個元素就可以了!我們來看看Haskell中的Maybe
型別
data Maybe a = Nothing | Just a
Maybe中有兩個元素,分別是代表”沒有”的Nothing以及”存在”的Just,Just可以想像一個泛型的容器,裡面可以存放一個任意物件。其實如果有對null做好處理的話我覺的跟nullable的物件沒有差異,Maybe更接近概念上的不同,把東西是否存在往上提昇一個層次,null的語意上容易讓人忽略而不處裡,而Nothing作為值域的一個映射結果,跟其他元素應該是需要被同等看待的,此外函數式的語言中有各種的語法糖讓使用者方便的處裡Nothing。
回到C#中可以怎麼處理null呢?
空物件模式有很多種應用,其中一個是解決回傳值為null的問題,以前面會員的例子來說或許可以改成:
var person = GetMemberById(id);
Console.WriteLine(person.Name);
Member GetMemberById(long id)
{
return _dbContext.Member.FisrtOrDefault(x => x.Id == id) ?? new NoMember();
}
public class NoMember() : Member
{
}
因為GetMemberById
方法保證了一定有一個會員會被回傳,所以後面印出會員名稱的方法就不會出錯,接下來就是因應商業邏輯,要將這個空物件設計的多麼仔細,盡可能的定義出他的行為,只是這樣就會面臨兩個問題
C#後來加入了nullable的特性,並且在程式碼撰寫的時候可以針對null做判斷決定後續的程式碼流程,另外也有。
int? number = null;
if(number.HasValue) Console.WriteLine(number.Value);
else Console.WriteLine("數字不存在");
// Result:數字不存在
//也可以這樣寫
if(number is {} value) Console.WriteLine(value);
else Console.WriteLine("數字不存在");
另外在.Net6中可以在專案設定將Null-state analysis開啟,這樣會在編譯的時候對null reference做檢查,並且有不同的嚴格程度,強烈建議開啟。另外文件上也有提供局部修改null嚴格程度的方法
#nullable enable
// 這裡可以寫nullable的型別
#nullable disable
// 這邊不能寫nullable的型別
//--------------------------------------
Console.WriteLine(person!.name); // 這邊告訴編譯器person不會是null
在FP的設計風格中,對於Maybe的概念其實有很多不同的名稱,C#有一個針對函數式設計的套件LanguageExt使用Option : Some() | None
, Option表示這個值是可選的,如果有值的話包在Some裡面。要實現Option其使可以有各種作法
public abstract class Option
{
public static Option Some<T>(T data)
{
return new Some<T>(data);
}
public static Option None()
{
return new None();
}
}
public class Some<T> : Option
{
public Some(T data)
{
this.Data = data;
}
public T Data { get; init; }
}
public class None : Option
{
}
public static class OptionExtension
{
public static Option Map<TSource, TResult>(this Option source, Func<TSource, TResult> operate)
{
return source switch
{
None => source,
Some<TSource> { Data: var sourceData } =>
Option.Some(operate(sourceData)),
_ => throw new NotSupportedException()
};
}
}
這邊是我想像中Option
的一種實做,其實少考慮非常多東西。C#過去對FP支援沒那麼多的時候,LanguageExt套件擴充了很多功能,然而隨著C#的發展,本身的語法就已經十分強大,在這邊其實只是想要展示一下OptionExtension中的Map方法,是函數是語言中的重要觀念,另外可以看到switch expression中對於Some的解構,就是C#7/8中加入的功能,也是FP中經常使用的pattern match。
其實觀察Map方法很明顯的可以發現,如果將空值的概念提昇到Option的層級,在撰寫程式碼的時候會有不一樣的想法,我自己覺得Option可以幫助工程師在設計函數的時候就完整的考慮空值的情況,而null的處理比較偏向被動,好處是可以減少很多的程式碼,畢竟實務層面並不是所有的null都有可能導致錯誤,不過另一方面也是很多系統錯誤的原因,但話又說回來,C#的Null Conditional Operators真的很方便,轉個形式其實就很FP了。