我們先回到昨天的程式碼:
int pi = 3.14159;
相信大家看一眼就知道,如果把3.14159放到一個int
型別的參數,程式理所當然的會報錯。然而從數學的角度來看,如果我們將int
視為整數的集合,3.14159顯然並不在定義裡面!這邊有一個函數:
如果做成圖形
如果將 -1 放到函數裡會得到什麼呢?首先-1本身就不在函數的定義域裡面,所以這個問題一開始就不成立。函數是兩個集合的對應關係,並且這個關係是一對一或是多對一的,並不存在一對多或是一對無,換句話說,只要有一個值可以放到方法裡面,我們就必定可以得到對應的函數輸出。
如何在C#中表示函數的概念呢?首先最直覺的就是方法(method)了,在物件導向中,我們通常會將方法視為依附於某個物件的行為,這邊可以先提到副作用的概念(side effect),利用方法去改變物件內部或外部的狀態。在FP裡面,函數都應該是無副作用的純函數(pure function),這些預計在後面的章節詳細介紹,回到C#中可以透過下面這些功能來實現函數:
其中Dictionary可能是個比較特別的想法,但是我們確實可以用字典定義每一個輸入值對應的輸出,
那麼我們一開始提到的開根號的函數要如何透過程式語言實現呢?
在純FP的語言Haskell中我們大概可以這樣寫
root :: Floating a => a -> a
root 0 = 0
root 0.01 = 0.1
root 0.04 = 0.2
......
雖然看起來很好笑,但我們的確可以把root
函數所有的值都列舉出來,而-1並沒有被定義,所以輸入的話程式會報錯,有沒有觀察到,這就相當於C#的Dictionary了!
從定義域跟值域的關係,我們可以學到什麼呢?如果將不在定義域中的元素放到一個函數中,程式就應該要報錯並且告知是因為錯誤的輸入,最直覺的方法是在函數的一開始就進行驗證
public string CheckBMIHealth(float weight,float height)
{
if (weight > 300)
throw new ArgumentException("請不要拿大象開玩笑",nameof(weight));
//,,,
}
這種將驗證寫在方法開頭的地方,很容易因為後續的驗證越來越多導致驗證的區塊越來越大,方法內真正重要的邏輯就會變得難以閱讀,C#中曾經有一個類別**Contract**去支援這種先驗條件,不過後來就沒有繼續維護了,OOP的角度會將這些驗證抽成獨立的驗證器物件,不管怎樣總之先把函數的輸入包成一個獨立的物件,然後將驗證抽到驗證器物件中
public class PersonBodyInfoEntity
{
public float Weight { get; set; }
public float Height { get; set; }
}
public class Validator
{
public static void Validate(Person person)
{
if (person.Weight > 300)
throw new ArgumentException("請不要拿大象開玩笑","Weight");
}
}
public string CheckBMIHealth(PersonBodyInfoEntity person)
{
Validator.Validate(person);
}
但回想到函數不應該允許定義域以外的元素,其實也可以這麼做,微軟官方文件對ArgumentException
其實也有提到這樣的作法。
public class PersonBodyInfoEntity
{
private float _weight;
public float Weight
{
get => _weight;
set
{
if (value > 300)
throw new ArgumentException("請不要拿大象開玩笑",nameof(value));
_weight = value;
}
}
public float Height { get; set; }
}
這些討論其實已經有一點接近剖面導向(AOP)了,至於那一種作法比較好就見仁見智了,這其實關係到整個系統的設計,總之可以強調的是,如果在函數最開始的地方就確保輸入值的正確性,可以大大的提高系統的穩健(缺點是會多很多的code),讓工程師對自己的程式碼更有信心,明天會從值域與null處理開始,講到Map、Linq以及高階函數。
最後補上一點查資料的時候才知道的事情,或許大家有聽過不建議在程式中拋出例外,但實際上並不是單純的不建議,而是要在語意清楚的情況下選擇適合的例外型別,以上面的範例來說由於是引數錯誤所以選用ArgumentException
,詳細可以閱讀**使用標準例外狀況類型**